Merge pull request #796 from MBombeck/release/v1.37.20

release/v1.37.20
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-14 14:49:54 +02:00
commit 3a2c0e757e
158 files changed
+10247 -922

No files matched your search

+25
View File
@@ -1,5 +1,28 @@
# Changelog
## [1.37.20] — 2026-08-14
### Added
- A checkup reminder can now be postponed or skipped. Postponing moves the next due date to a day you pick; skipping closes the current cycle and restarts the interval from today, without pretending the checkup happened. Every completion and every skip lands in a new history, kept per reminder, including whether it happened on time, so the checkups page and the API can show how a reminder has actually been handled over the years.
- The dashboard hero can show the reminder rail instead of the health score. The choice lives in the dashboard settings and does nothing until you change it.
- The restore dialog shows what a backup contains before you confirm: per-type counts read from the stored file, with an honest note when the preview cannot be computed.
- A mental-health screening shows its per-item answers on the detail view, so you can see which questions carried the score, not only the total.
- Document search understands German and English medical synonyms: searching for "Blutdruck" finds a report that says "blood pressure", and the other way round.
- A deleted custom-metric entry can be brought back with one tap for a short while instead of being gone the moment you tap.
### Changed
- Onboarding says plainly that Apple Health data arrives through the iPhone app, and the Apple Health card reports the backfill progress the server actually sees: accepted counts, the oldest reading, and how recently data last arrived.
- The measurements and mood pages keep their filters in the address bar, so a filtered view survives a reload and can be shared as a link, the way documents already worked.
- The sleep-stage tooltip labels one night's figures as main sleep, naps and total sleep instead of an averaged label that fit none of them.
- The daily digest is available offline like the rest of the app shell.
- Backups carry the checkup reminders and their new completion history end to end, and an appointment or vaccination that references a reminder keeps that link across a restore.
### Fixed
- Apple Health ECG exports in the single-column layout import correctly. Apple writes two CSV shapes; the importer understood only the paired one and skipped every recording in the other, reporting each row as malformed. Both layouts parse now, and the declared unit is converted before validation.
## [1.37.19] — 2026-08-14
### Changed
@@ -20,6 +43,8 @@
- A simultaneous double-submit of the same mood entry reports "duplicate" with the winning entry instead of a confusing error.
- The intake-event API description now documents every field the server actually returns, and the workout list description matches the real nullability, so generated clients decode without surprises.
## [1.37.18] — 2026-08-14
### Fixed
- Coach reminders tied to a moment ("remind me when I next log blood pressure", "next time I open the app") now actually fire. The app has offered these since v1.22 and confirmed them, but nothing ever evaluated them; a matching measurement or the next app open now surfaces the reminder, and the daily sweep backs it up.
+2148 -70
View File
File diff suppressed because it is too large. Load diff
+28
View File
@@ -131,6 +131,34 @@ without touching the file:
- `HEALTHLOG_IMAGE_REF=:v1.16.5` — pin to a release tag (still mutable).
- `HEALTHLOG_IMAGE_REF=@sha256:<digest>` — pin to the immutable digest.
#### Bumping a digest pin by hand
A digest-pinned instance never follows a release on its own — an image
promote does not touch the pin, so moving it is part of shipping. The
mechanic, end to end:
1. Resolve the new release's digest from the registry (the manifest-list
digest, which covers both architectures):
```sh
docker buildx imagetools inspect ghcr.io/mbombeck/healthlog:v1.37.20 \
| grep -m1 Digest
```
2. Set `HEALTHLOG_IMAGE_REF=@sha256:<that digest>` where the compose
environment lives — the `.env` next to the compose file, or the
deployment platform's env vars for the app service.
3. Deploy as usual.
4. Verify `/api/version` on the target — and verify it **three times a
few seconds apart**, not once: during the container swap a single
probe can be answered by the outgoing container (or a cache) and read
as success while the old release keeps running. Three stable answers
reporting the shipped `version` + `buildSha` are the signal; a deploy
that finishes suspiciously fast (under ~30 s) is a warning that
nothing was actually pulled.
> **A pin is sticky, and a stale pin is silent.** Once set, every later deploy
> ships that reference until someone changes it. The deploy still reports
> success, the tag still builds, the image still publishes, and the running
+3
View File
@@ -243,6 +243,9 @@ test.describe("Apple Health guidance", () => {
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
metricFreshness: [],
// #778 — nothing accepted yet, so the card must show no
// progress section for this pending-first-sync account.
syncProgress: { recordsAccepted: 0, oldestMeasuredAt: null },
},
error: null,
}),
+174 -122
View File
@@ -123,7 +123,7 @@
},
"profile": {
"update": {
"nothingSaved": "Es wurde nichts gespeichert. Bitte das markierte Feld korrigieren und erneut versuchen.",
"nothingSaved": "Es wurde nichts gespeichert. Korrigiere das markierte Feld und versuche es erneut.",
"invalidBody": "Die Profilaktualisierung konnte nicht gelesen werden.",
"emailInUse": "Diese E-Mail-Adresse wird bereits verwendet."
}
@@ -516,6 +516,9 @@
"heroItemsTitle": "Highlights für heute",
"heroItemsDescription": "Wähle aus, welche Eintragstypen in den Highlights für heute erscheinen.",
"heroItemsNotificationNote": "Benachrichtigungseinstellungen werden separat verwaltet.",
"heroContentTitle": "Obere Karte zeigt",
"heroContentScore": "Gesundheitsscore und Tagesüberblick",
"heroContentReminders": "Highlights für heute",
"heroItems": {
"coach_checkin": "Coach-Check-ins",
"dose_window": "Medikamentendosen",
@@ -664,13 +667,13 @@
"formReset": "Formular zurücksetzen",
"saveError": "Fehler beim Speichern",
"duplicateTimestamp": "Zum gewählten Zeitpunkt existiert bereits ein Messwert dieses Typs.",
"serverOwnedSource": "Werte aus einer verbundenen Quelle lassen sich nicht bearbeiten – Zeitpunkt und Notiz bleiben änderbar.",
"serverOwnedSource": "Werte aus einer verbundenen Quelle lassen sich nicht bearbeiten — Zeitpunkt und Notiz bleiben änderbar.",
"deleteError": "Fehler beim Löschen",
"deletedToast": "Messwert gelöscht",
"restoredToast": "Messwert wiederhergestellt",
"restoreError": "Wiederherstellen fehlgeschlagen",
"invalidValue": "Bitte einen gültigen Wert eingeben.",
"invalidTimestamp": "Bitte einen gültigen Zeitpunkt wählen.",
"invalidValue": "Gib einen gültigen Wert ein.",
"invalidTimestamp": "Wähle einen gültigen Zeitpunkt.",
"loginRequired": "Bitte anmelden, um Messwerte zu sehen.",
"allTypes": "Alle Typen",
"measurementCount": "{count} Messwerte",
@@ -805,7 +808,7 @@
"deletedToast": "Eintrag gelöscht",
"restoredToast": "Eintrag wiederhergestellt",
"restoreError": "Wiederherstellen fehlgeschlagen",
"invalidTimestamp": "Bitte einen gültigen Zeitpunkt wählen.",
"invalidTimestamp": "Wähle einen gültigen Zeitpunkt.",
"deleteConfirmTitle": "Eintrag löschen?",
"deleteConfirmDescription": "Dieser Stimmungseintrag wird gelöscht. Direkt danach kannst du das kurz rückgängig machen.",
"pageInfo": "Seite {page} von {total}",
@@ -916,7 +919,7 @@
"archive": "Archivieren",
"restore": "Wiederherstellen",
"archivedTitle": "Archivierte Tags",
"archivedDescription": "Archivierte Tags bleiben in deinen bisherigen Einträgen erhalten – sie verschwinden nur aus der Auswahl.",
"archivedDescription": "Archivierte Tags bleiben in deinen bisherigen Einträgen erhalten — sie verschwinden nur aus der Auswahl.",
"archivedEmpty": "Nichts archiviert.",
"purgeTitle": "Tag endgültig löschen?",
"purgeBody": "Der Tag wird aus {count} bisherigen Einträgen entfernt. Das lässt sich nicht rückgängig machen.",
@@ -941,7 +944,7 @@
},
"sectionMoreTags": "Weitere Tags",
"sectionNoteDetails": "Notiz & Details",
"quickTagsHelp": "Häufig genutzte Tags – ein Tipp genügt",
"quickTagsHelp": "Häufig genutzte Tags — ein Tipp genügt",
"quickTagsExpand": "Weitere",
"loadError": "Stimmungseinträge konnten nicht geladen werden.",
"sectionLevelA": "Gefühlslage",
@@ -1119,8 +1122,8 @@
"dialogDescription": "Diese Medikamente sind jetzt fällig. Bestätige, um sie alle als genommen zu erfassen.",
"confirm": "Alle einnehmen ({count})",
"successToast": "{count} Einnahmen erfasst",
"partialToast": "{taken} erfasst, {failed} fehlgeschlagen – bitte erneut versuchen",
"failedToast": "Keine Einnahme erfasst – bitte erneut versuchen"
"partialToast": "{taken} erfasst, {failed} fehlgeschlagen — bitte erneut versuchen",
"failedToast": "Keine Einnahme erfasst — bitte erneut versuchen"
},
"logIntake": {
"dialogTitle": "Einnahme erfassen",
@@ -1151,7 +1154,7 @@
"importUploadFile": "JSON-Datei hochladen",
"importPaste": "JSON hier einfügen…",
"importSelected": "Ausgewählt: {name}",
"importFileLoaded": "Datei \"{name}\" geladen",
"importFileLoaded": "Datei „{name}“ geladen",
"importInvalidJson": "Datei enthält kein gültiges JSON",
"importNoArray": "Kein Array gefunden",
"importFailed": "Import fehlgeschlagen",
@@ -1204,10 +1207,10 @@
"skipped": "Übersprungen",
"intakeToastTaken": "{name} als genommen erfasst",
"intakeToastSkipped": "{name} übersprungen",
"intakeToastFailed": "{name} konnte nicht erfasst werden – bitte erneut versuchen",
"intakeToastFailed": "{name} konnte nicht erfasst werden — bitte erneut versuchen",
"intakeUndo": "Rückgängig",
"intakeUndone": "Eintrag zurückgenommen",
"intakeUndoFailed": "Rückgängig fehlgeschlagen – im Verlauf korrigieren",
"intakeUndoFailed": "Rückgängig fehlgeschlagen — im Verlauf korrigieren",
"glp1LastInjection": "{label}",
"glp1NextInjectionDays": "{label} (in {days} Tagen)",
"glp1NextInjectionToday": "heute",
@@ -1238,7 +1241,7 @@
"deleteCta": "Löschen",
"deleteError": "Eintrag konnte nicht gelöscht werden",
"errorSeverity": "Schweregrad muss zwischen 1 und 5 liegen",
"errorEntry": "Bitte ein Symptom auswählen",
"errorEntry": "Wähle ein Symptom aus",
"categories": {
"gi": "Magen-Darm",
"metabolic": "Stoffwechsel",
@@ -1287,7 +1290,7 @@
"rolling": "Methotrexat 7,5 mg alle 7 Tage nach der letzten Spritze"
},
"error": {
"empty": "Bitte zuerst eine Beschreibung eingeben.",
"empty": "Gib zuerst eine Beschreibung ein.",
"tooLong": "Beschreibung muss unter 2000 Zeichen bleiben.",
"rateLimit": "Zu viele Anfragen — bitte kurz warten und erneut versuchen.",
"noProvider": "Kein KI-Anbieter konfiguriert — bitte in den Einstellungen einrichten.",
@@ -1443,7 +1446,7 @@
"dosesPerUnitHint": "Dosen pro Pen / Ampulle — leer lassen, wenn kein Bestand getrackt wird.",
"short": "Dosis",
"unitsPerDoseLabel": "Einheiten pro Dosis",
"unitsPerDoseHint": "z. B. 2 Tabletten à 2 mg für eine 4-mg-Dosis – oder ½ Tablette bei geteilter Dosis"
"unitsPerDoseHint": "z. B. 2 Tabletten à 2 mg für eine 4-mg-Dosis — oder ½ Tablette bei geteilter Dosis"
},
"step4": {
"title": "Über welchen Zeitraum nimmst du es?",
@@ -1593,7 +1596,7 @@
},
"injectionSiteBodyOutlineAriaLabel": "Körperumriss",
"injectionSitePickerAriaLabel": "Injektionsstelle auswählen",
"injectionSiteRecommendedAriaLabel": "{site} – empfohlene nächste Stelle",
"injectionSiteRecommendedAriaLabel": "{site} — empfohlene nächste Stelle",
"injectionSiteLegendAriaLabel": "Legende der Markierungen",
"injectionSiteLegendRecommended": "Empfohlene nächste Stelle",
"injectionSiteLegendLastUsed": "Zuletzt verwendete Stelle",
@@ -1707,7 +1710,7 @@
"dialogTitle": "Einnahme bearbeiten",
"takenAtLabel": "Eingenommen um",
"scheduledForHint": "Geplant: {dateTime}",
"farFromScheduledWarning": "Liegt weit vom geplanten Zeitpunkt entfernt – Datum prüfen.",
"farFromScheduledWarning": "Liegt weit vom geplanten Zeitpunkt entfernt — Datum prüfen.",
"skippedLabel": "Übersprungen",
"save": "Speichern",
"cancel": "Abbrechen",
@@ -1751,10 +1754,10 @@
},
"addDialog": {
"title": "Einnahme hinzufügen",
"description": "Dosis nachtragen – auch rückwirkend oder außerplanmäßig.",
"description": "Dosis nachtragen — auch rückwirkend oder außerplanmäßig.",
"takenAtLabel": "Genommen um",
"skippedLabel": "Übersprungen",
"nudgeHint": "Liegt nah am Slot {slot} – beim Speichern fragen wir, ob zugeordnet werden soll.",
"nudgeHint": "Liegt nah am Slot {slot} — beim Speichern fragen wir, ob zugeordnet werden soll.",
"cancel": "Abbrechen",
"submit": "Speichern"
},
@@ -1772,7 +1775,7 @@
"unpinnedToast": "Zuordnung gelöst",
"dueContext": "fällig gewesen: {time} ({delta})",
"confirmTitle": "Slot {time} zuordnen?",
"confirmBody": "Die Einnahme zählt für diesen Slot als verspätet – die Quote wird nicht geschönt. Die Zuordnung lässt sich jederzeit wieder lösen.",
"confirmBody": "Die Einnahme zählt für diesen Slot als verspätet — die Quote wird nicht geschönt. Die Zuordnung lässt sich jederzeit wieder lösen.",
"confirmCancel": "Abbrechen",
"confirmAction": "Zuordnen"
}
@@ -1832,7 +1835,7 @@
"title": "Verlauf löschen",
"helper": "Es sind {count} Einnahmen für dieses Medikament gespeichert.",
"button": "Verlauf löschen",
"dialogTitle": "Verlauf wirklich löschen?",
"dialogTitle": "Verlauf löschen?",
"dialogBody": "Die {count} Einnahmen werden unwiderruflich gelöscht. Das Medikament selbst bleibt.",
"toast": "Verlauf gelöscht",
"failed": "Verlauf konnte nicht gelöscht werden."
@@ -1852,7 +1855,7 @@
"saveFailed": "Phasen konnten nicht gespeichert werden."
},
"api": {
"caption": "Endpunkt für „{name}\"",
"caption": "Endpunkt für „{name}“",
"copyUrl": "URL kopieren",
"urlCopied": "URL kopiert",
"mintToken": "Token erzeugen",
@@ -2035,7 +2038,7 @@
"timesLabel": "Einnahmezeiten",
"fromLabel": "Von",
"untilLabel": "Bis",
"untilHint": "„Bis\" ist der Tag, ab dem der nachfolgende Plan galt (ausschließlich).",
"untilHint": "„Bis“ ist der Tag, ab dem der nachfolgende Plan galt (ausschließlich).",
"validationTimes": "Mindestens eine Uhrzeit angeben.",
"validationRangeMissing": "Von- und Bis-Datum angeben.",
"validationRangeOrder": "Das Von-Datum muss vor dem Bis-Datum liegen.",
@@ -2096,16 +2099,16 @@
"tableStockDoses": "{count} Dosen",
"tableStockRunway": "≈ {days} Tage übrig",
"cardLowStockRunway": "Vorrat: ≈ {days} Tage",
"takeActionFor": "Einnehmen – {name}",
"skipActionFor": "Überspringen – {name}",
"takeActionFor": "Einnehmen — {name}",
"skipActionFor": "Überspringen — {name}",
"reorderTitle": "Reihenfolge anpassen",
"reorderDescription": "Bestimme die Reihenfolge deiner Medikamente – sie gilt für die Karten- und die Tabellenansicht.",
"reorderDescription": "Bestimme die Reihenfolge deiner Medikamente — sie gilt für die Karten- und die Tabellenansicht.",
"reorderSaved": "Reihenfolge gespeichert",
"reorderSaveFailed": "Reihenfolge konnte nicht gespeichert werden",
"customize": "Medikamente anpassen",
"efficacy": {
"title": "Wirkung",
"intro": "Wie sich die Messwerte, die dieses Medikament beeinflussen soll, rund um den Beginn der Einnahme entwickelt haben. Es zeigt den zeitlichen Zusammenhang – eine Assoziation, keine Ursache – und keine Empfehlung zu Behandlung oder Dosis.",
"intro": "Wie sich die Messwerte, die dieses Medikament beeinflussen soll, rund um den Beginn der Einnahme entwickelt haben. Es zeigt den zeitlichen Zusammenhang — eine Assoziation, keine Ursache — und keine Empfehlung zu Behandlung oder Dosis.",
"notEligible": "Für dieses Medikament gibt es keinen zugehörigen Messwert.",
"startFallback": "Es ist kein Startdatum hinterlegt, daher dient der erste erfasste Wert als Bezugspunkt.",
"noStart": "Hinterlege ein Startdatum, um die Werte vor und nach Beginn zu vergleichen.",
@@ -2128,16 +2131,16 @@
"error": "Diese Änderung konnte nicht gespeichert werden. Bitte erneut versuchen."
},
"beforeAfter": {
"summary": "Ø {before} davor → Ø {after} seitdem – eine Veränderung von {delta}.",
"summary": "Ø {before} davor → Ø {after} seitdem — eine Veränderung von {delta}.",
"counts": "Basierend auf {beforeCount} Werten davor und {afterCount} seitdem.",
"needBefore": "Noch zu wenige Werte vor dem Beginn – für einen Vergleich werden etwa {weeks} Wochen davor und danach benötigt.",
"needAfter": "Noch zu wenige Werte seit dem Beginn – für einen Vergleich werden etwa {weeks} Wochen davor und danach benötigt.",
"needBefore": "Noch zu wenige Werte vor dem Beginn — für einen Vergleich werden etwa {weeks} Wochen davor und danach benötigt.",
"needAfter": "Noch zu wenige Werte seit dem Beginn — für einen Vergleich werden etwa {weeks} Wochen davor und danach benötigt.",
"needStart": "Hinterlege ein Startdatum, um die Werte vor und nach Beginn zu vergleichen.",
"needData": "Noch zu wenige Werte – für einen Vergleich werden etwa {weeks} Wochen vor und nach dem Beginn benötigt."
"needData": "Noch zu wenige Werte — für einen Vergleich werden etwa {weeks} Wochen vor und nach dem Beginn benötigt."
},
"insights": {
"title": "Medikamenten-Wirkung",
"subtitle": "Was mit den Zielwerten deiner Medikamente zeitlich zusammenfällt – nur ein Zusammenhang, kein Urteil.",
"subtitle": "Was mit den Zielwerten deiner Medikamente zeitlich zusammenfällt — nur ein Zusammenhang, kein Urteil.",
"link": "Wirkung öffnen",
"empty": "Noch nicht genug Daten, um deine Medikamente mit ihren Zielwerten in Beziehung zu setzen."
},
@@ -2566,7 +2569,7 @@
"emptyAction": "Briefing erstellen",
"loadingLabel": "Briefing wird zusammengestellt",
"noProviderTitle": "Kein KI-Anbieter verbunden",
"noProviderDescription": "Richte HealthLog auf ein lokales Modell (Ollama, LM Studio – nichts verlässt dein Netzwerk) oder hinterlege deinen eigenen OpenAI- oder Anthropic-Schlüssel, und das tägliche Briefing fasst deine eigenen Messwerte in klarer Sprache zusammen. Die Einrichtung dauert etwa eine Minute.",
"noProviderDescription": "Richte HealthLog auf ein lokales Modell (Ollama, LM Studio — nichts verlässt dein Netzwerk) oder hinterlege deinen eigenen OpenAI- oder Anthropic-Schlüssel, und das tägliche Briefing fasst deine eigenen Messwerte in klarer Sprache zusammen. Die Einrichtung dauert etwa eine Minute.",
"noProviderAction": "Zu den KI-Einstellungen",
"staleNoProviderHint": "Dieses Briefing kann nicht aktualisiert werden — es ist kein KI-Anbieter verbunden.",
"refreshFailedHint": "Briefing konnte nicht aktualisiert werden — es wird das letzte angezeigt.",
@@ -2784,7 +2787,7 @@
"settingsVerbosityDefault": "Standard",
"settingsVerbosityDetailed": "Ausführlich",
"settingsVerbosityConciseHint": "Der knappe Tonfall hält Antworten ohnehin kurz — die Ausführlichkeit ist dabei fest auf „Kurz“ gesetzt.",
"settingsSourcesPointer": "Welche Datenquellen der Coach nutzen darf und das Standard-Analysefenster verwaltest du direkt im Coach-Chat (Leiste „Worauf ich zugreife\").",
"settingsSourcesPointer": "Welche Datenquellen der Coach nutzen darf und das Standard-Analysefenster verwaltest du direkt im Coach-Chat (Leiste „Worauf ich zugreife“).",
"settingsSourcesPointerLink": "Coach öffnen",
"settingsExcludeLabel": "Metriken ausschließen",
"settingsExcludeHint": "Der Coach sieht keine Daten der ausgewählten Metriken.",
@@ -2919,7 +2922,7 @@
"correlationBpCompliance": "Wie hängt meine Therapietreue mit meinem Blutdruck zusammen? Geh mit mir durch, was dieser Zusammenhang für mich bedeutet.",
"correlationMoodPulse": "Gibt es einen echten Zusammenhang zwischen meiner Stimmung und meinem Puls? Hilf mir, diesen Zusammenhang zu lesen.",
"correlationWeightWeekday": "Folgt mein Gewicht einem Wochenmuster? Was sagt mir dieser Wochentags-Zusammenhang?",
"recommendation": "Erzähl mir mehr zu dieser Empfehlung: \"{text}\" — warum trifft sie auf mich zu, und was sollte ich tun?"
"recommendation": "Erzähl mir mehr zu dieser Empfehlung: „{text}“ — warum trifft sie auf mich zu, und was sollte ich tun?"
},
"assessmentPrompt": "Erkläre mir meine {metric}-Einschätzung — was bedeutet sie und was sollte ich tun?",
"attach": {
@@ -2927,7 +2930,7 @@
"menuUploadNew": "Neues Dokument hochladen",
"trigger": "Dokumente anhängen",
"pickerTitle": "Dokumente anhängen",
"pickerDescription": "Der Coach liest nur die angehängten Dokumente – nicht deine Gesundheitsdaten.",
"pickerDescription": "Der Coach liest nur die angehängten Dokumente — nicht deine Gesundheitsdaten.",
"pickerSearchPlaceholder": "Dokumente durchsuchen…",
"pickerEmpty": "Noch keine Dokumente in deinem Tresor.",
"pickerNotIndexed": "Noch nicht indexiert",
@@ -2935,13 +2938,13 @@
"pickerConfirm": "Anhängen ({count})",
"pillRemove": "{title} entfernen",
"indexing": "Wird indexiert…",
"indexingHint": "Ein Dokument wird noch indexiert – du kannst senden, sobald es bereit ist.",
"fencedHint": "Diese Unterhaltung läuft ohne Zugriff auf deine Gesundheitsdaten – der Coach liest nur die angehängten Dokumente.",
"indexingHint": "Ein Dokument wird noch indexiert — du kannst senden, sobald es bereit ist.",
"fencedHint": "Diese Unterhaltung läuft ohne Zugriff auf deine Gesundheitsdaten — der Coach liest nur die angehängten Dokumente.",
"documentsAttachedOne": "1 Dokument angehängt",
"documentsAttachedOther": "{count} Dokumente angehängt",
"railBadge": "Dokument-Chat (abgeschottet)",
"uploadError": "Upload fehlgeschlagen. Bitte versuche es erneut.",
"notFoundError": "Dieser Dokumentlink ist nicht mehr gültig – es wird ein normaler Chat gestartet."
"notFoundError": "Dieser Dokumentlink ist nicht mehr gültig — es wird ein normaler Chat gestartet."
},
"interrupted": {
"note": "Diese Antwort wurde unterbrochen, bevor sie fertig war.",
@@ -3076,11 +3079,11 @@
},
"explainer": {
"bloodPressureBody": "Der Blutdruck ist die Kraft, die das Blut auf die Gefäßwände ausübt, angegeben als systolisch zu diastolisch (Spitzenwert beim Herzschlag über dem Ruhewert dazwischen). Dauerhaft hohe Werte belasten Herz und Gefäße auf Dauer.",
"pulseBody": "Der Puls gibt an, wie oft das Herz pro Minute schlägt. Eine Einzelmessung spiegelt den Moment wider – in Ruhe liegt er meist bei 60–100 Schlägen pro Minute und steigt natürlicherweise bei Bewegung, Stress oder Koffein.",
"pulseBody": "Der Puls gibt an, wie oft das Herz pro Minute schlägt. Eine Einzelmessung spiegelt den Moment wider — in Ruhe liegt er meist bei 60–100 Schlägen pro Minute und steigt natürlicherweise bei Bewegung, Stress oder Koffein.",
"weightBody": "Das Körpergewicht ist eines der einfachsten Signale für die langfristige Energiebilanz und die allgemeine Gesundheit. Der Trend über Wochen zählt weit mehr als ein einzelner Wert, der mit Flüssigkeit, Ernährung und Tageszeit schwankt.",
"bmiBody": "Der BMI ist dein Gewicht geteilt durch deine Körpergröße zum Quadrat und ordnet dich einer groben Gewichtskategorie zu. Er ist ein Orientierungswert, keine Diagnose, und unterscheidet nicht zwischen Muskeln und Fett.",
"sleepBody": "Die Schlafdauer ist die tatsächlich geschlafene Zeit pro Nacht, aufgeteilt in Leicht-, Tief- und REM-Schlaf. Die meisten Erwachsenen brauchen etwa sieben bis neun Stunden; gleichmäßige Menge und Qualität unterstützen Erholung, Stimmung und Stoffwechsel.",
"moodBody": "Die Stimmungserfassung hält auf einer einfachen Skala fest, wie du dich fühlst. Regelmäßiges Protokollieren zeigt Muster – wie Schlaf, Aktivität oder Medikamente mit dem Befinden zusammenhängen –, die ein einzelner Tag nie offenbart.",
"moodBody": "Die Stimmungserfassung hält auf einer einfachen Skala fest, wie du dich fühlst. Regelmäßiges Protokollieren zeigt Muster — wie Schlaf, Aktivität oder Medikamente mit dem Befinden zusammenhängen –, die ein einzelner Tag nie offenbart.",
"medicationsBody": "Die Therapietreue zeigt, wie zuverlässig du deine Medikamente wie verordnet einnimmst, hier als Anteil der erwarteten Dosen, die du erfasst hast. Eine gleichmäßige Einnahme entscheidet oft darüber, ob eine Therapie wie beabsichtigt wirkt.",
"restingHrBody": "Der Ruhepuls ist die Herzfrequenz in völliger Ruhe, meist über Nacht gemessen. Ein niedriger Ruhepuls deutet in der Regel auf eine bessere Herz-Kreislauf-Fitness hin, ein steigender Trend kann ein frühes Zeichen für Stress oder Krankheit sein.",
"hrvBody": "Die HRV ist die kleine Schwankung der Zeit zwischen aufeinanderfolgenden Herzschlägen. Eine höhere Variabilität spiegelt meist ein gut erholtes, anpassungsfähiges Nervensystem wider, ein anhaltender Rückgang kann auf Erschöpfung, Stress oder mangelnde Erholung hinweisen.",
@@ -3126,14 +3129,14 @@
"recoveryBody": "Die Erholung zeigt, wie gut sich dein Körper regeneriert hat — gestützt auf nächtliche Signale wie Herzfrequenzvariabilität und Ruhepuls. Es ist ein geräteabgeleiteter Näherungswert an deinem eigenen Ausgangswert, keine klinische Messung, und zwischen Geräten nicht vergleichbar. Ein hoher Wert ist grünes Licht, mehr zu geben; ein niedrigerer deutet an, dass dein System noch aufbaut.",
"strainBody": "Die Belastung erfasst, wie viel kardiovaskuläre und körperliche Last du deinem Körper zugemutet hast — von Workouts bis zu einem aktiven Tag auf den Beinen. Es ist ein geräteabgeleiteter Index, gemessen an deinem eigenen üblichen Aufwand, keine klinische Messung, und der Kennwert ist zwischen Geräten nicht vergleichbar.",
"stressBody": "Stress ist hier eine Geräte-Heuristik aus Mustern der Herzfrequenzvariabilität — keine Cortisol-Messung und nicht, wie du dich bewusst fühlst, und auch kein klinischer Stress-Score. Lies ihn an deinem eigenen Ausgangswert; zwischen Geräten ist er nicht vergleichbar. Ein niedrigerer Wert deutet auf einen ruhigen, gut regulierten Zustand hin; ein höherer ist eine Einladung, Erholung einzuplanen.",
"recoveryPageBody": "Die Erholung fasst die nächtlichen Signale deines Wearables zusammen – Recharge, Belastung, Load sowie deine Herzfrequenz- und Energiewerte des Tages – und zeigt, wie gut sich dein Körper erholt hat. Achte eher auf das Muster über mehrere Tage als auf einen einzelnen Wert; ein schwächerer Wert ist eine Einladung, kürzerzutreten, kein Alarm.",
"recoveryPageBody": "Die Erholung fasst die nächtlichen Signale deines Wearables zusammen — Recharge, Belastung, Load sowie deine Herzfrequenz- und Energiewerte des Tages — und zeigt, wie gut sich dein Körper erholt hat. Achte eher auf das Muster über mehrere Tage als auf einen einzelnen Wert; ein schwächerer Wert ist eine Einladung, kürzerzutreten, kein Alarm.",
"pulsePressureBody": "Der Pulsdruck ist die Differenz zwischen deinem systolischen und diastolischen Wert (systolisch minus diastolisch). Als allgemeine Orientierung sind etwa 40 mmHg in Ruhe typisch; eine große Differenz über rund 60 mmHg im mittleren Alter kann auf versteifte Arterien hinweisen, eine kleine Differenz unter etwa 25 mmHg kann mit niedrigem Schlagvolumen einhergehen (StatPearls/NIH 2023). Ein Gesprächsanlass, keine Diagnose.",
"meanArterialPressureBody": "Der mittlere arterielle Druck ist der durchschnittliche Druck, den deine Organe über einen Herzschlag sehen – grob der diastolische Wert plus ein Drittel der Differenz zum systolischen. Als allgemeine Orientierung liegt ein Ruhewert bei etwa 70–100 mmHg (StatPearls/NIH 2023). Die oft genannte Grenze von ~60 mmHg ist ein Konzept der Akutmedizin, kein Alarm für die Selbstmessung.",
"cycleEducationBody": "Dein Zyklus durchläuft vier hormongesteuerte Phasen, und seine Länge und Regelmäßigkeit sind ein gut zugängliches Fenster auf die allgemeine Gesundheit. Als allgemeine Orientierung dauern Zyklen bei Erwachsenen typischerweise 21–35 Tage (klinische Referenzen nennen 24–38), mit einer Blutung von rund fünf Tagen (ACOG 2015). Die Lutealphase ist der stabile Anker, daher schätzt man den Eisprung am besten durch Rückrechnen von der nächsten erwarteten Periode – nie über einen festen „Tag 14“. Ein vorhergesagtes fruchtbares Fenster ist eine Schätzung, die mehrere erfasste Zyklen braucht und ungefähr bleibt, und die Basaltemperatur bestätigt den Eisprung im Nachhinein, statt ihn vorherzusagen. Dies ist keine Verhütungsmethode.",
"meanArterialPressureBody": "Der mittlere arterielle Druck ist der durchschnittliche Druck, den deine Organe über einen Herzschlag sehen — grob der diastolische Wert plus ein Drittel der Differenz zum systolischen. Als allgemeine Orientierung liegt ein Ruhewert bei etwa 70–100 mmHg (StatPearls/NIH 2023). Die oft genannte Grenze von ~60 mmHg ist ein Konzept der Akutmedizin, kein Alarm für die Selbstmessung.",
"cycleEducationBody": "Dein Zyklus durchläuft vier hormongesteuerte Phasen, und seine Länge und Regelmäßigkeit sind ein gut zugängliches Fenster auf die allgemeine Gesundheit. Als allgemeine Orientierung dauern Zyklen bei Erwachsenen typischerweise 21–35 Tage (klinische Referenzen nennen 24–38), mit einer Blutung von rund fünf Tagen (ACOG 2015). Die Lutealphase ist der stabile Anker, daher schätzt man den Eisprung am besten durch Rückrechnen von der nächsten erwarteten Periode — nie über einen festen „Tag 14“. Ein vorhergesagtes fruchtbares Fenster ist eine Schätzung, die mehrere erfasste Zyklen braucht und ungefähr bleibt, und die Basaltemperatur bestätigt den Eisprung im Nachhinein, statt ihn vorherzusagen. Dies ist keine Verhütungsmethode.",
"sleepHygieneBody": "Einige evidenzbasierte Gewohnheiten unterstützen den Schlaf: gleichbleibende Zubett- und Aufstehzeiten auch am Wochenende, helles Morgenlicht und gedämpfte Abende, ein kühles, dunkles und ruhiges Zimmer sowie der Verzicht auf Koffein am Nachmittag, späten Alkohol und große späte Mahlzeiten (CDC 2024). Regelmäßigkeit sagt die Gesundheit mindestens so stark voraus wie die Dauer, und die Gesamtschlafzeit eines Wearables ist weit verlässlicher als seine Aufteilung in Tief-, Leicht- und REM-Schlaf."
},
"bpDerived": {
"pulsePressure": "Der Pulsdruck (die Differenz zwischen deinen Werten) beträgt etwa {value} mmHg – rund 40 ist in Ruhe typisch.",
"pulsePressure": "Der Pulsdruck (die Differenz zwischen deinen Werten) beträgt etwa {value} mmHg — rund 40 ist in Ruhe typisch.",
"meanArterialPressure": "Der mittlere arterielle Druck (der Durchschnitt, den deine Organe sehen) liegt bei etwa {value} mmHg, normalerweise um 70–100.",
"caveat": "Diese Werte werden als allgemeine Orientierung aus deiner letzten Messung abgeleitet, nicht als Diagnose."
},
@@ -3167,8 +3170,9 @@
},
"sleep": {
"title": "Schlaf",
"description": "Phasen-Aufschlüsselung und nächtliche Dauer – gezogen aus Apple Health.",
"headlineTitle": "Durchschnitt pro Nacht",
"description": "Phasen-Aufschlüsselung und nächtliche Dauer — gezogen aus Apple Health.",
"mainSleep": "Hauptschlaf",
"totalSleep": "Schlaf gesamt",
"compositionTitle": "Phasen-Verteilung",
"compositionSubtitle": "Letzte {nights} Nächte",
"compositionAriaLabel": "Schlafphasen-Verteilung über {nights} Nächte",
@@ -3184,11 +3188,11 @@
"inBed": "Im Bett",
"nap": "Nickerchen",
"napCount": "{count} Nickerchen",
"unavailable": "Noch keine Phasen-Daten – Schlafphasen erscheinen hier, sobald eine verbundene Quelle sie liefert."
"unavailable": "Noch keine Phasen-Daten — Schlafphasen erscheinen hier, sobald eine verbundene Quelle sie liefert."
},
"empty": {
"title": "Noch keine Schlafdaten",
"description": "Verbinde eine Quelle wie Apple Health oder ein Wearable – oder erfasse Schlaf manuell – und diese Ansicht füllt sich automatisch.",
"description": "Verbinde eine Quelle wie Apple Health oder ein Wearable — oder erfasse Schlaf manuell — und diese Ansicht füllt sich automatisch.",
"action": "Geräte verwalten"
},
"debt": {
@@ -3385,11 +3389,11 @@
"clinical": {
"title": "Glukose-Überblick",
"subtitle": "Ein Blick auf deine letzten {days} Tage, aus {count} Messungen.",
"spotCaveat": "Das sind Schätzungen aus einzelnen Stichproben-Messungen, kein Profil eines kontinuierlichen Sensors – lies sie als Richtung, nicht als klinisches Urteil.",
"spotCaveat": "Das sind Schätzungen aus einzelnen Stichproben-Messungen, kein Profil eines kontinuierlichen Sensors — lies sie als Richtung, nicht als klinisches Urteil.",
"labsLink": "Laborergebnisse? HbA1c und Nüchternglukose aus einer Blutabnahme trägst du unter Labor ein.",
"learningTitle": "Lerne deine Glukose noch kennen",
"learningBody": "Bisher sind das {count} über {days}. Mit etwas mehr Verlauf wird das Bild klarer – mess weiter oder lass es ein verbundenes Gerät ergänzen.",
"learningBodyEmpty": "Sobald ein paar Messungen vorliegen – von dir erfasst oder von einem Gerät synchronisiert – nehmen Zeit-im-Zielbereich, Durchschnitt und Schwankung hier Gestalt an.",
"learningBody": "Bisher sind das {count} über {days}. Mit etwas mehr Verlauf wird das Bild klarer — mess weiter oder lass es ein verbundenes Gerät ergänzen.",
"learningBodyEmpty": "Sobald ein paar Messungen vorliegen — von dir erfasst oder von einem Gerät synchronisiert — nehmen Zeit-im-Zielbereich, Durchschnitt und Schwankung hier Gestalt an.",
"readingsCount": "{count} Messungen",
"readingsCountOne": "1 Messung",
"daysCount": "{days} Tage",
@@ -3427,7 +3431,7 @@
"intro": "Forschungsnahe Kennzahlen für den genaueren Blick. Jede fasst deine Messungen zu einer risikogewichteten Zahl zusammen.",
"jIndex": {
"label": "J-Index",
"help": "Ein einzelner Wert aus Durchschnitt und Streuung – niedriger steht für engere Kontrolle."
"help": "Ein einzelner Wert aus Durchschnitt und Streuung — niedriger steht für engere Kontrolle."
},
"lbgi": {
"label": "Risiko für Tiefwerte (LBGI)",
@@ -3723,7 +3727,7 @@
"inTargetCaption": "der letzten Tage im guten Stimmungsbereich",
"narrative": {
"title": "Das fällt auf",
"weekdayDip": "Deine Stimmung sinkt am stärksten am {weekday} – {delta} unter deinem Wochenschnitt.",
"weekdayDip": "Deine Stimmung sinkt am stärksten am {weekday} — {delta} unter deinem Wochenschnitt.",
"weekdayPeak": "{weekday} ist durchgehend dein bester Tag mit einem Schnitt von {value}.",
"trendUp": "Deine Stimmung steigt über die letzten {days} Tage (+{delta} gegenüber dem Vorzeitraum).",
"trendDown": "Deine Stimmung sinkt über die letzten {days} Tage (-{delta} gegenüber dem Vorzeitraum).",
@@ -3732,7 +3736,7 @@
"tagLift": "Tage mit „{tag}“ liegen im Schnitt {delta} über deiner allgemeinen Stimmung.",
"tagDrop": "Tage mit „{tag}“ liegen {delta} unter deinem Durchschnitt.",
"inTarget": "Du warst an {pct} % der letzten 30 Tage in deinem guten Stimmungsbereich.",
"streak": "Du hast deine Stimmung {days} Tage in Folge erfasst – weiter so.",
"streak": "Du hast deine Stimmung {days} Tage in Folge erfasst — weiter so.",
"timeOfDay": "Am {bucket} fühlst du dich am besten, im Schnitt {value}."
},
"timeOfDay": {
@@ -3803,7 +3807,7 @@
"title": "Was auffällt",
"subheading": "Entdeckte Zusammenhänge",
"footer": "Herausgefiltert aus {tested} Tag-zu-Tag-Paaren, die über all deine Werte getestet wurden, nach der Korrektur für Fehlentdeckungen.",
"disclaimer": "Statistisch gefiltert, aber weiterhin beobachtend – ein Muster, das einen Blick wert ist, keine Ursache.",
"disclaimer": "Statistisch gefiltert, aber weiterhin beobachtend — ein Muster, das einen Blick wert ist, keine Ursache.",
"moodUp": "Höheres {factor} geht in deinen Daten tendenziell mit besserer Stimmung am Folgetag einher.",
"moodDown": "Höheres {factor} geht in deinen Daten tendenziell mit schlechterer Stimmung am Folgetag einher.",
"outcomeUp": "Bessere Stimmung geht in deinen Daten tendenziell mit höherem {factor} am Folgetag einher.",
@@ -4183,7 +4187,7 @@
"regenerateSettling": "Die Einschätzung wird noch erstellt",
"regenerateSettleFailed": "Die Aktualisierung konnte nicht abgeschlossen werden. Bitte später erneut versuchen.",
"deviceScore": {
"learning": "Deine Basislinie wird noch gelernt – ein paar weitere Nächte vervollständigen den Verlauf.",
"learning": "Deine Basislinie wird noch gelernt — ein paar weitere Nächte vervollständigen den Verlauf.",
"average": "30-Tage-Durchschnitt {value}",
"unitScore": "Punkte",
"unitMinutes": "Min.",
@@ -4196,14 +4200,14 @@
},
"recovery": {
"title": "Erholung",
"description": "Wie gut sich dein Körper erholt hat – die Erholungs-, Belastungs- und Load-Signale, die dein Wearable über Nacht aufzeichnet.",
"description": "Wie gut sich dein Körper erholt hat — die Erholungs-, Belastungs- und Load-Signale, die dein Wearable über Nacht aufzeichnet.",
"scoreCrossLink": "Den zusammengesetzten Erholungs-Score ansehen",
"emptyTitle": "Noch keine Erholungsdaten",
"empty": "Noch keine Erholungsdaten. Verbinde ein WHOOP-, Polar- oder Oura-Konto, dann füllen sich diese Werte, sobald dein Gerät synchronisiert.",
"block": {
"ansChargeExplainer": "Wie aufgeladen dein autonomes Nervensystem in den Tag startet – ein Recharge-Wert aus deinen nächtlichen Herz- und Atemsignalen.",
"ansChargeExplainer": "Wie aufgeladen dein autonomes Nervensystem in den Tag startet — ein Recharge-Wert aus deinen nächtlichen Herz- und Atemsignalen.",
"dayStrainExplainer": "Die kardiovaskuläre Tagesbelastung auf der Belastungsskala deines Geräts. Lies sie im Verhältnis zu deiner Erholung, nicht als Ziel für sich.",
"workoutStrainExplainer": "Wie fordernd ein einzelnes Training auf derselben Belastungsskala war – die kardiovaskuläre Last dieser Einheit.",
"workoutStrainExplainer": "Wie fordernd ein einzelnes Training auf derselben Belastungsskala war — die kardiovaskuläre Last dieser Einheit.",
"cardioLoadExplainer": "Ein Trainingslast-Näherungswert, der die akut aufgebaute kardiovaskuläre Belastung abbildet. Vergleiche ihn mit deinen eigenen letzten Tagen.",
"averageHeartRateExplainer": "Deine durchschnittliche Herzfrequenz über den ganzen Tag bzw. Zyklus, wie dein Wearable sie meldet.",
"maxHeartRateExplainer": "Deine höchste Herzfrequenz über den ganzen Tag bzw. Zyklus, wie dein Wearable sie meldet.",
@@ -4479,7 +4483,7 @@
},
"wrapUp": {
"title": "Das war die Tour",
"body": "Diese Führung läuft einmalig – sie öffnet sich nicht von selbst erneut. Alles Gezeigte bleibt erhalten, und du kannst Funktionen jederzeit unter Einstellungen → Module ein- oder ausschalten."
"body": "Diese Führung läuft einmalig — sie öffnet sich nicht von selbst erneut. Alles Gezeigte bleibt erhalten, und du kannst Funktionen jederzeit unter Einstellungen → Module ein- oder ausschalten."
}
}
},
@@ -4494,8 +4498,8 @@
},
"goals": {
"title": "Was möchtest du erfassen?",
"body": "Wähle die Bereiche, die dir wichtig sind – wir zeigen sie zuerst auf deinem Dashboard an, und sie helfen dir zu entscheiden, was du als Nächstes einrichtest.",
"helpHint": "Ein bis drei Schwerpunkte funktionieren erfahrungsgemäß am besten – mehr geht aber jederzeit.",
"body": "Wähle die Bereiche, die dir wichtig sind — wir zeigen sie zuerst auf deinem Dashboard an, und sie helfen dir zu entscheiden, was du als Nächstes einrichtest.",
"helpHint": "Ein bis drei Schwerpunkte funktionieren erfahrungsgemäß am besten — mehr geht aber jederzeit.",
"options": {
"weightManagement": "Gewicht im Griff",
"bpTracking": "Blutdruck",
@@ -4507,7 +4511,7 @@
},
"source": {
"title": "Quelle verbinden",
"body": "Werte automatisch übernehmen oder weiter manuell eintragen. Hier wird nichts für dich eingerichtet – jede Karte zeigt nur, wo du sie verbindest. Geräte-Quellen brauchen eine kostenlose Entwickler-App auf deinem eigenen Konto (etwa 5 Minuten); die Anleitung auf jeder Karte führt dich durch.",
"body": "Werte automatisch übernehmen oder weiter manuell eintragen. Hier wird nichts für dich eingerichtet — jede Karte zeigt nur, wo du sie verbindest. Geräte-Quellen brauchen eine kostenlose Entwickler-App auf deinem eigenen Konto (etwa 5 Minuten); die Anleitung auf jeder Karte führt dich durch.",
"recommended": "Empfohlen",
"manual": {
"title": "Manuelle Eingabe",
@@ -4517,18 +4521,19 @@
"title": "Withings",
"body": "Waagen, Blutdruckmesser und Tracker von Withings synchronisieren automatisch, sobald verbunden.",
"cta": "Withings verbinden",
"setupCta": "In den Einstellungen einrichten",
"setupHint": "Braucht zuerst deine eigene, kostenlose Withings-Entwickler-App – etwa 5 Minuten, einmalig."
"setupCta": "Einrichten",
"setupHint": "Braucht zuerst deine eigene, kostenlose Withings-Entwickler-App — etwa 5 Minuten, einmalig."
},
"more": {
"title": "Weitere Quellen",
"body": "Verbinde eine davon in den Einstellungen, wann immer du bereit bist – die meisten brauchen zuerst eine kostenlose Entwickler-App auf deinem eigenen Konto.",
"body": "Verbinde eine davon in den Einstellungen, wann immer du bereit bist — die meisten brauchen zuerst eine kostenlose Entwickler-App auf deinem eigenen Konto.",
"appleHealth": "Apple Health",
"whoop": "WHOOP",
"oura": "Oura",
"polar": "Polar",
"nightscout": "Nightscout",
"fitbit": "Fitbit"
"fitbit": "Fitbit",
"appleHealthHint": "Apple Health hat keine Web-Verbindung — schließe das Onboarding hier ab und richte Apple Health danach in der HealthLog-App auf deinem iPhone ein."
},
"import": "Bestehende Daten importieren"
},
@@ -4553,7 +4558,7 @@
"title": "Alles bereit",
"body": "Willkommen an Bord. Dein Dashboard wartet.",
"returnCta": "Zum Dashboard",
"learning": "Manche Einblicke – Glukose, Schlafrhythmus, Blutdrucktrends, Zyklus – schärfen sich in den ersten ein bis zwei Wochen, sobald sich deine Daten ansammeln. Leere Kacheln heißen jetzt nur, dass wir deine Basislinie noch lernen.",
"learning": "Manche Einblicke — Glukose, Schlafrhythmus, Blutdrucktrends, Zyklus — schärfen sich in den ersten ein bis zwei Wochen, sobald sich deine Daten ansammeln. Leere Kacheln heißen jetzt nur, dass wir deine Basislinie noch lernen.",
"connectCta": "Gerät verbinden",
"logCta": "Ersten Wert eintragen",
"importCta": "Bestehende Daten importieren",
@@ -4592,33 +4597,33 @@
"anamnesis": {
"title": "Zu deiner Gesundheit (optional)",
"subtitle": "Etwas Kontext, damit HealthLog deine Werte fairer einordnet.",
"intro": "Teile so viel oder so wenig, wie du möchtest – jedes Feld ist optional und jederzeit in den Einstellungen änderbar.",
"intro": "Teile so viel oder so wenig, wie du möchtest — jedes Feld ist optional und jederzeit in den Einstellungen änderbar.",
"conditionsLabel": "Bestehende Erkrankungen",
"conditionsPlaceholder": "z. B. Bluthochdruck, Typ-2-Diabetes",
"conditionsHint": "Hilft HealthLog, deine Werte im Kontext einzuordnen, statt Erwartbares zu melden.",
"allergiesLabel": "Allergien oder Unverträglichkeiten",
"allergiesPlaceholder": "z. B. Penicillin, Laktose",
"privacyNote": "Verschlüsselt gespeichert und nur für besseren Kontext genutzt. Medikamente fragen wir hier nicht ab – die kannst du jederzeit auf der Medikamenten-Seite ergänzen."
"privacyNote": "Verschlüsselt gespeichert und nur für besseren Kontext genutzt. Medikamente fragen wir hier nicht ab — die kannst du jederzeit auf der Medikamenten-Seite ergänzen."
},
"ai": {
"panelTitle": "So sehen Insights aus",
"panelIntro": "Coach, das tägliche Briefing und die Einschätzungen je Messwert übersetzen deine eigenen Zahlen in Klartext. So sieht das aus – bevor du irgendetwas verbindest.",
"panelIntro": "Coach, das tägliche Briefing und die Einschätzungen je Messwert übersetzen deine eigenen Zahlen in Klartext. So sieht das aus — bevor du irgendetwas verbindest.",
"showSample": "Beispiel-Briefing ansehen",
"sampleTitle": "Tägliches Briefing",
"sampleTag": "Beispiel",
"sampleAriaLabel": "Beispielhaftes tägliches Briefing",
"sampleBody": "Gewicht in zwei Wochen um 1,2 kg gesunken. Ruhepuls stabil bei 58 bpm. Blutdruck an 6 von 7 Tagen im Zielbereich – der einzige hohe Wert folgte auf ein spätes, salziges Abendessen.",
"sampleCaption": "Nur ein Beispiel – nicht deine eigenen Daten. So liest sich ein Briefing, sobald Insights aktiv sind.",
"sampleBody": "Gewicht in zwei Wochen um 1,2 kg gesunken. Ruhepuls stabil bei 58 bpm. Blutdruck an 6 von 7 Tagen im Zielbereich — der einzige hohe Wert folgte auf ein spätes, salziges Abendessen.",
"sampleCaption": "Nur ein Beispiel — nicht deine eigenen Daten. So liest sich ein Briefing, sobald Insights aktiv sind.",
"ladderTitle": "Drei Wege, es zu aktivieren",
"ladderLocalTitle": "Lokales Modell – nichts verlässt dein Netzwerk",
"ladderLocalBody": "Richte HealthLog auf Ollama oder LM Studio auf deinem eigenen Rechner aus. Kostenlos und vollständig privat – der ruhige Standard.",
"ladderLocalTitle": "Lokales Modell — nichts verlässt dein Netzwerk",
"ladderLocalBody": "Richte HealthLog auf Ollama oder LM Studio auf deinem eigenen Rechner aus. Kostenlos und vollständig privat — der ruhige Standard.",
"ladderByokTitle": "Eigenen Schlüssel verwenden",
"ladderByokBody": "Nutze deinen eigenen OpenAI- oder Anthropic-Schlüssel. Er läuft über dein Konto und deine Abrechnung, und die API-Stufe hält deine Daten standardmäßig aus dem Training heraus.",
"ladderOauthTitle": "Mit einem Abo anmelden",
"ladderOauthBody": "Verbinde ein ChatGPT-Konto – keine Kosten pro Token, aber die Bedingungen der Verbraucherstufe können Inhalte fürs Training nutzen. Deshalb eine bewusste Wahl, nie der Standard für deine Gesundheitsdaten.",
"keylessLine": "HealthLog ist auch ohne KI vollständig nutzbar. Deine Kacheln, Diagramme, Trends und Exporte funktionieren alle – KI ergänzt nur den Klartext obendrauf.",
"ladderOauthBody": "Verbinde ein ChatGPT-Konto — keine Kosten pro Token, aber die Bedingungen der Verbraucherstufe können Inhalte fürs Training nutzen. Deshalb eine bewusste Wahl, nie der Standard für deine Gesundheitsdaten.",
"keylessLine": "HealthLog ist auch ohne KI vollständig nutzbar. Deine Kacheln, Diagramme, Trends und Exporte funktionieren alle — KI ergänzt nur den Klartext obendrauf.",
"setupCta": "Einrichten (etwa eine Minute)",
"sharedKeyNote": "Diese Instanz stellt bereits einen gemeinsamen KI-Anbieter bereit, deshalb funktionieren Insights für dich sofort – keine Einrichtung nötig."
"sharedKeyNote": "Diese Instanz stellt bereits einen gemeinsamen KI-Anbieter bereit, deshalb funktionieren Insights für dich sofort — keine Einrichtung nötig."
}
},
"gettingStarted": {
@@ -5227,14 +5232,14 @@
"storedDays": "{count} Tage Wetter gespeichert.",
"home": {
"title": "Heimatort",
"description": "Ein grober Standort auf Stadtebene – niemals eine genaue Spur. Wetter und Tageslicht werden für diesen Ort an Tagen ohne Ortszeitraum abgerufen, ab dem Tag der Festlegung.",
"description": "Ein grober Standort auf Stadtebene — niemals eine genaue Spur. Wetter und Tageslicht werden für diesen Ort an Tagen ohne Ortszeitraum abgerufen, ab dem Tag der Festlegung.",
"current": "Aktuell: {label}",
"none": "Noch kein Heimatort festgelegt.",
"since": "Wetter wird ab {date} erfasst. Frühere Tage bleiben leer, bis du einen Ortszeitraum hinzufügst."
},
"travel": {
"title": "Ortszeiträume",
"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.",
"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",
@@ -5555,7 +5560,7 @@
"coachMemory": {
"title": "Woran sich der Coach erinnert",
"description": "Dauerhafte Angaben, die du dem Coach mitgeteilt hast.",
"empty": "Der Coach hat noch keine Angaben gespeichert. Im Gespräch merkt er sich dauerhafte Details, die du teilst – deine Ziele, Beschwerden, Vorlieben – und du kannst sie hier jederzeit löschen.",
"empty": "Der Coach hat noch keine Angaben gespeichert. Im Gespräch merkt er sich dauerhafte Details, die du teilst — deine Ziele, Beschwerden, Vorlieben — und du kannst sie hier jederzeit löschen.",
"learnedPrefix": "gelernt {when}",
"categoryPreference": "Vorlieben",
"categoryCondition": "Beschwerden",
@@ -5572,7 +5577,7 @@
"forgetAllConfirmBody": "Dadurch werden alle gespeicherten Angaben entfernt. Der Coach beginnt beim nächsten Gespräch von vorn. Das lässt sich nicht rückgängig machen.",
"forgetAllConfirmAction": "Alles vergessen",
"cancel": "Abbrechen",
"summaryNote": "Der Coach behält außerdem eine kurze, fortlaufende Zusammenfassung jedes Gesprächs. Dafür gibt es keine Einzelsteuerung – wenn du ein Gespräch löschst, wird auch dessen Zusammenfassung entfernt.",
"summaryNote": "Der Coach behält außerdem eine kurze, fortlaufende Zusammenfassung jedes Gesprächs. Dafür gibt es keine Einzelsteuerung — wenn du ein Gespräch löschst, wird auch dessen Zusammenfassung entfernt.",
"loadError": "Konnte nicht laden, woran sich der Coach erinnert.",
"forgotToast": "Angabe vergessen.",
"forgotError": "Konnte diese Angabe nicht vergessen. Bitte erneut versuchen.",
@@ -5637,7 +5642,7 @@
"autoRead": {
"title": "Dokumente automatisch mit KI lesen",
"subLabel": "Dokumente werden von deinem konfigurierten KI-Anbieter gelesen und indiziert, ohne dass du jedes Dokument einzeln bestätigen musst.",
"honesty": "Wenn aktiviert, wird jedes hochgeladene Dokument an deinen konfigurierten KI-Anbieter gesendet, um gelesen, beschrieben und für die Suche indiziert zu werden – ohne Bestätigung pro Dokument. Das Einschalten wirkt außerdem rückwirkend: Bereits hochgeladene Dokumente ohne Zusammenfassung werden ebenfalls gesendet, bis zu 200 pro Einschaltvorgang. Das geschieht auf dem Server, und der Inhalt des Dokuments verlässt dabei diese Maschine in Richtung dieses Anbieters. Wenn dein konfigurierter Anbieter eine Abo-Verbindung ist (ein angemeldetes KI-Konto statt eines API-Schlüssels), gelten dessen eigene Datenschutzeinstellungen – Abo-Anbieter dürfen den Inhalt zur Verbesserung ihrer Modelle verwenden, und kein Auftragsverarbeitungsvertrag deckt das ab. Dokumente, die von einem selbst gehosteten lokalen Modell gelesen werden, verlassen deinen Server nie. Standardmäßig aus; aktiviere es nur, wenn du diesen Kompromiss für deine Dokumente akzeptierst.",
"honesty": "Wenn aktiviert, wird jedes hochgeladene Dokument an deinen konfigurierten KI-Anbieter gesendet, um gelesen, beschrieben und für die Suche indiziert zu werden — ohne Bestätigung pro Dokument. Das Einschalten wirkt außerdem rückwirkend: Bereits hochgeladene Dokumente ohne Zusammenfassung werden ebenfalls gesendet, bis zu 200 pro Einschaltvorgang. Das geschieht auf dem Server, und der Inhalt des Dokuments verlässt dabei diese Maschine in Richtung dieses Anbieters. Wenn dein konfigurierter Anbieter eine Abo-Verbindung ist (ein angemeldetes KI-Konto statt eines API-Schlüssels), gelten dessen eigene Datenschutzeinstellungen — Abo-Anbieter dürfen den Inhalt zur Verbesserung ihrer Modelle verwenden, und kein Auftragsverarbeitungsvertrag deckt das ab. Dokumente, die von einem selbst gehosteten lokalen Modell gelesen werden, verlassen deinen Server nie. Standardmäßig aus; aktiviere es nur, wenn du diesen Kompromiss für deine Dokumente akzeptierst.",
"confirm": "Verstanden — aktivieren",
"cancel": "Abbrechen"
},
@@ -5967,7 +5972,7 @@
},
"notificationStatus": {
"title": "Zustellstatus",
"description": "Pro Kanal aktueller Status; \"Automatisch deaktiviert\" stoppt nach dauerhaften Fehlern.",
"description": "Status je Kanal — „Automatisch deaktiviert“ heißt: nach dauerhaften Fehlern gestoppt.",
"emptyDescription": "Richte unter Einstellungen › Integrationen › Zustellkanäle einen Kanal ein.",
"stateActive": "Aktiv",
"stateAutoDisabled": "Automatisch deaktiviert",
@@ -6169,7 +6174,7 @@
}
},
"integrationRedirectGuide": {
"intro": "Die Verbindung läuft über deine eigene {provider}-Entwickler-App – etwa 5 Minuten Einrichtung:",
"intro": "Die Verbindung läuft über deine eigene {provider}-Entwickler-App — etwa 5 Minuten Einrichtung:",
"step1": "Erstelle eine App im {provider}-Entwicklerportal.",
"step2": "Setze deren Weiterleitungs-/Callback-URL auf:",
"step3": "Trage die dort erhaltene Client-ID und das Client-Secret oben ein."
@@ -6283,7 +6288,7 @@
"privacy": {
"encryption": {
"title": "Verschlüsselt gespeichert",
"summary": "Deine sensiblen Daten werden mit {algorithm} verschlüsselt gespeichert – über {columns} Felder in {models} Datensatztypen.",
"summary": "Deine sensiblen Daten werden mit {algorithm} verschlüsselt gespeichert — über {columns} Felder in {models} Datensatztypen.",
"statement": "Deine sensiblen Daten werden mit AES-256-GCM verschlüsselt gespeichert."
},
"stored": {
@@ -6377,7 +6382,7 @@
},
"appleHealth": {
"title": "Apple Health",
"description": "Die Live-Synchronisierung erfolgt über die HealthLog-iOS-App – nicht über eine Web- oder OAuth-Verbindung.",
"description": "Die Live-Synchronisierung erfolgt über die HealthLog-iOS-App — nicht über eine Web- oder OAuth-Verbindung.",
"status": {
"unavailable": "Apple-Health-Status nicht verfügbar"
},
@@ -6387,6 +6392,13 @@
"importNote": "Du brauchst ältere Daten oder kannst die iOS-App nicht nutzen? Der Apple-Health-XML/ZIP-Upload ist ein einmaliger Ersatzweg und aktiviert keine Live-Synchronisierung.",
"importAction": "Einmaligen Import öffnen",
"staleNote": "Seit über einer Woche sind keine Apple-Health-Daten angekommen. Öffne die HealthLog-App auf deinem iPhone und prüfe die Apple-Health-Berechtigungen.",
"progress": {
"title": "Sync-Fortschritt",
"receivedLabel": "Empfangene Datensätze",
"oldestLabel": "Älteste erreichte Messung",
"flowing": "In der letzten Stunde sind Daten angekommen — die iPhone-App liefert weiter.",
"waiting": "Warten auf weitere Daten von der iPhone-App. Die Übertragung läuft weiter, wenn die App geöffnet ist oder iOS ihr Hintergrundzeit gewährt — eine ruhige Phase während des ersten großen Abgleichs ist normal, kein Fehler."
},
"delivery": {
"title": "Übermittlung",
"lastTriggerLabel": "Letzte Synchronisierung kam",
@@ -6578,7 +6590,7 @@
"title": "Modulverfügbarkeit",
"description": "Schalte ein Modul für den gesamten Server ab.",
"saveError": "Modulverfügbarkeit konnte nicht aktualisiert werden.",
"detail": "Ein hier deaktiviertes Modul ist für jedes Konto aus – unabhängig von den persönlichen Einstellungen. Kerndaten (Gewicht, Blutdruck, Puls, Medikamente) sind immer aktiv und lassen sich nicht abschalten."
"detail": "Ein hier deaktiviertes Modul ist für jedes Konto aus — unabhängig von den persönlichen Einstellungen. Kerndaten (Gewicht, Blutdruck, Puls, Medikamente) sind immer aktiv und lassen sich nicht abschalten."
},
"invites": {
"title": "Einladungen",
@@ -6689,7 +6701,7 @@
"auditDetails": "Details",
"deleteAllData": "Alle Daten global löschen",
"deleteAllDescription": "Löscht alle Datensätze aller Konten; die Konten selbst und ihre Zugangsdaten bleiben erhalten.",
"deleteAllConfirm": "Alle Daten wirklich löschen?",
"deleteAllConfirm": "Alle Daten löschen?",
"deleteAllConfirmDescription": "Dieser Vorgang löscht unwiderruflich alle Datensätze aller Konten in der gesamten Datenbank, nicht eine Auswahl von Tabellen. Erhalten bleiben: die Nutzerkonten und ihre Anmelde-Zugangsdaten, die Konfiguration der Instanz und die Berechtigungen, über die betreute Profile erreichbar bleiben. Ein Beleg für diesen Vorgang wird danach ins Audit-Log geschrieben; die Audit-Historie davor geht mit allem anderen.",
"deleteButton": "Alle Daten löschen",
"deleteAllPromptLabel": "Tippe {token} zum Fortfahren",
@@ -6891,6 +6903,23 @@
"restoreTitle": "Aus Backup wiederherstellen",
"restoreDescription": "Dies ersetzt ALLE aktuellen Daten von {username} mit dem Snapshot von {when}. Vorhandene Messungen, Medikamente, Einnahmen, Stimmungseinträge, Benachrichtigungs-Kanäle, Push-Abos und Telegram-Lösch-Aufträge werden entfernt. Falls im Snapshot enthalten, überschreiben instanzweite Einstellungen die Konfiguration der gesamten Installation.",
"restorePromptLabel": "Tippe RESTORE zum Fortfahren",
"previewTitle": "Inhalt der Sicherung",
"previewLoading": "Inhalt wird gelesen…",
"previewUnavailable": "Inhalt konnte nicht gelesen werden — die Wiederherstellung bleibt möglich.",
"previewEmpty": "Die Datei enthält keine Datensätze.",
"previewOther": "Weitere Datensätze",
"previewMeasurements": "Messwerte",
"previewMedications": "Medikamente und Einnahmen",
"previewMood": "Stimmungseinträge",
"previewLabs": "Laborwerte",
"previewDocuments": "Dokumente",
"previewWorkouts": "Workouts",
"previewCycles": "Zyklusdaten",
"previewVisits": "Arztbesuche",
"previewVaccinations": "Impfungen",
"previewReminders": "Vorsorge-Erinnerungen",
"previewCustomMetrics": "Eigene Messgrößen",
"previewHealthScores": "Score-Verlauf",
"restoreConfirm": "Jetzt wiederherstellen",
"restoreInProgress": "Wiederherstellung läuft…",
"restoreSuccess": "Wiederherstellung abgeschlossen",
@@ -6963,7 +6992,7 @@
},
"carrier": "Netzbetreiber",
"mfaRequired": "Zweiten Faktor verlangen",
"mfaRequiredDescription": "Jedes Konto ohne aktiven zweiten Faktor wird nach der Anmeldung zur Einrichtung geleitet. Das fordert Web-Nutzer zur Einrichtung auf – es blockiert jedoch keinen direkten API- oder nativen (Bearer-)Zugriff für noch nicht eingerichtete Konten.",
"mfaRequiredDescription": "Jedes Konto ohne aktiven zweiten Faktor wird nach der Anmeldung zur Einrichtung geleitet. Das fordert Web-Nutzer zur Einrichtung auf — es blockiert jedoch keinen direkten API- oder nativen (Bearer-)Zugriff für noch nicht eingerichtete Konten.",
"mfaEnforcedOn": "MFA an",
"mfaEnforcedOff": "MFA aus",
"mfaEnforcedOnHint": "Für diesen Nutzer ist ein zweiter Faktor erforderlich. Klicken, um die Anforderung aufzuheben.",
@@ -7738,7 +7767,7 @@
"empty": "Noch nicht genug Zyklen und Messwerte für ein Phasenmuster. Logge weiter — das füllt sich, je länger deine Historie wird."
},
"crosstab": {
"description": "Wie sich jeder Messwert zwischen deiner Luteal- und Follikelphase vergleicht – auf deinen eigenen Daten.",
"description": "Wie sich jeder Messwert zwischen deiner Luteal- und Follikelphase vergleicht — auf deinen eigenen Daten.",
"pairLabel": "{metric} · Luteal vs. Follikel",
"detail": "Luteal {lutealAvg} {unit} ({lutealDays} Tage) vs. Follikel {follicularAvg} {unit} ({follicularDays} Tage).",
"metricRestingHeartRate": "Ruhepuls",
@@ -7778,7 +7807,7 @@
},
"context": {
"MENSTRUAL": "Die Hormonspiegel sind jetzt am niedrigsten, weshalb sich diese Tage wie ein ruhiger Neustart anfühlen können. Manche bemerken Krämpfe, leichteren Schlaf oder den Wunsch, einen Gang zurückzuschalten; andere kommen fast unverändert durch. All das liegt im normalen Rahmen, und wie es sich anfühlt, kann von Zyklus zu Zyklus wechseln.",
"FOLLICULAR": "Mit steigendem Östrogen beschreiben viele diese Tage als hell und offen – der Schlaf fällt oft leichter und der Kopf fühlt sich klarer an. Die Basaltemperatur bleibt meist im niedrigeren Bereich, bis kurz vor dem Eisprung. Die Länge dieser Phase schwankt am stärksten – von Person zu Person und von Zyklus zu Zyklus.",
"FOLLICULAR": "Mit steigendem Östrogen beschreiben viele diese Tage als hell und offen — der Schlaf fällt oft leichter und der Kopf fühlt sich klarer an. Die Basaltemperatur bleibt meist im niedrigeren Bereich, bis kurz vor dem Eisprung. Die Länge dieser Phase schwankt am stärksten — von Person zu Person und von Zyklus zu Zyklus.",
"OVULATORY": "Das Östrogen erreicht kurz vor dem Eisprung seinen Höhepunkt, oft begleitet von einem kurzen Stimmungshoch und einem leichten Anstieg der Basaltemperatur in den ein, zwei Tagen danach. Der Zervixschleim wird in diesem Fenster meist klarer und dehnbarer. Ein kurzes Ziehen auf einer Seite bemerken manche; viele spüren gar nichts, und beides ist völlig normal.",
"LUTEAL": "Das Progesteron führt durch die zweite Zyklushälfte, und die Basaltemperatur liegt meist etwas höher als zuvor im Zyklus. Manche bemerken im Lauf der Tage Veränderungen bei Schlaf, Appetit, Stimmung oder Energie, während andere durchgehend ausgeglichen bleiben. Das Muster ist persönlich und sieht von Monat zu Monat oft anders aus."
},
@@ -7865,8 +7894,8 @@
"ariaUnknown": "Noch kein aktiver Zyklus",
"caption": "deines Zyklus",
"firstPeriodCta": "Erste Periode erfassen",
"periodOverdue": "Deine Periode ist später als üblich – die Tageszählung pausiert, bis die nächste Periode erfasst ist.",
"periodOverdueDays": "Deine Periode ist {days} Tage später als üblich – die Tageszählung pausiert, bis die nächste Periode erfasst ist.",
"periodOverdue": "Deine Periode ist später als üblich — die Tageszählung pausiert, bis die nächste Periode erfasst ist.",
"periodOverdueDays": "Deine Periode ist {days} Tage später als üblich — die Tageszählung pausiert, bis die nächste Periode erfasst ist.",
"logPeriodCta": "Periode erfassen"
},
"calendar": {
@@ -7878,7 +7907,7 @@
"prevMonth": "Vorheriger Monat",
"nextMonth": "Nächster Monat",
"loading": "Kalender wird geladen…",
"learning": "Wir lernen deinen Zyklus – erfasse mindestens drei volle Zyklen, dann füllen sich fruchtbares Fenster, Eisprung und Phasen. Bis dahin zeigt der Kalender nur, was du erfasst hast.",
"learning": "Wir lernen deinen Zyklus — erfasse mindestens drei volle Zyklen, dann füllen sich fruchtbares Fenster, Eisprung und Phasen. Bis dahin zeigt der Kalender nur, was du erfasst hast.",
"legendSymptom": "Symptome",
"legendOvulationConfirmed": "Bestätigter Eisprung",
"flowSpotting": "Schmierblutung",
@@ -7994,7 +8023,7 @@
"remindersDescription": "Perioden-Erinnerungen pro Kanal in den Benachrichtigungseinstellungen aktivieren.",
"remindersLink": "Benachrichtigungseinstellungen",
"fertileReminder": "Erinnerung an das fruchtbare Fenster",
"fertileReminderDescription": "Erhalte einige Tage vor deinem geschätzten fruchtbaren Fenster einen Hinweis. Standardmäßig aus – aktiviere sie pro Kanal in den Benachrichtigungseinstellungen."
"fertileReminderDescription": "Erhalte einige Tage vor deinem geschätzten fruchtbaren Fenster einen Hinweis. Standardmäßig aus — aktiviere sie pro Kanal in den Benachrichtigungseinstellungen."
},
"testResult": {
"POSITIVE": "Positiv",
@@ -8027,15 +8056,15 @@
"fieldInfo": {
"bbt": "Deine Ruhetemperatur, morgens als Erstes vor dem Aufstehen gemessen. Nach dem Eisprung steigt sie leicht an (etwa 0,2–0,3 °C).",
"bbtLabel": "Was ist die Basaltemperatur?",
"mucus": "Der Ausfluss verändert sich im Zyklus – von trocken oder klebrig zu cremig, dann wässrig und spinnbar rund um die fruchtbarsten Tage.",
"mucus": "Der Ausfluss verändert sich im Zyklus — von trocken oder klebrig zu cremig, dann wässrig und spinnbar rund um die fruchtbarsten Tage.",
"mucusLabel": "Was ist Zervixschleim?",
"ovulationTest": "Ein Urintest für zu Hause, der den LH-Anstieg erkennt – den Hormonschub etwa 24–36 h vor dem Eisprung. Ein positives Ergebnis markiert die fruchtbarste Phase.",
"ovulationTest": "Ein Urintest für zu Hause, der den LH-Anstieg erkennt — den Hormonschub etwa 24–36 h vor dem Eisprung. Ein positives Ergebnis markiert die fruchtbarste Phase.",
"ovulationTestLabel": "Was ist ein Ovulationstest?",
"spotting": "Leichte Blutung außerhalb der Periode.",
"spottingLabel": "Was ist Schmierblutung zwischen den Perioden?",
"pregnancyTest": "Ergebnis eines Schwangerschaftstests (hCG) für zu Hause.",
"pregnancyTestLabel": "Was ist ein Schwangerschaftstest?",
"progesteroneTest": "Ein Test, der bestätigt, dass der Eisprung stattgefunden hat – das Progesteron steigt danach an.",
"progesteroneTest": "Ein Test, der bestätigt, dass der Eisprung stattgefunden hat — das Progesteron steigt danach an.",
"progesteroneTestLabel": "Was ist ein Progesterontest?",
"intercourse": "Ob an diesem Tag Geschlechtsverkehr stattgefunden hat.",
"intercourseLabel": "Was erfasst Geschlechtsverkehr?",
@@ -8085,6 +8114,23 @@
"delete": "Löschen",
"deleteConfirmTitle": "Erinnerung löschen?",
"deleteConfirmDescription": "Diese Vorsorge-Erinnerung wird dauerhaft gelöscht.",
"postpone": {
"menuItem": "Verschieben…",
"title": "Fälligkeit verschieben",
"description": "Verschiebt nur die aktuelle Fälligkeit — der reguläre Rhythmus bleibt unverändert.",
"chip": "+{days} Tage",
"dateLabel": "Neues Datum",
"confirm": "Verschieben"
},
"skip": {
"heading": "Diese Fälligkeit überspringen",
"explainer": "Wird ehrlich als übersprungen festgehalten — nicht als erledigt. Das Intervall startet ab heute neu.",
"action": "Überspringen",
"confirmTitle": "Fälligkeit überspringen?"
},
"snoozedUntil": "Verschoben bis {date}",
"skippedOn": "Übersprungen am {date}",
"skipCount": "{count}× übersprungen",
"disabledBadge": "Aus",
"nextDue": {
"none": "Nicht geplant",
@@ -8240,7 +8286,7 @@
"takenAt": "Probendatum",
"note": "Notiz",
"notePlaceholder": "Optionaler Kontext zu diesem Wert",
"requiredError": "Bitte einen Wert eingeben.",
"requiredError": "Gib einen Wert ein.",
"save": "Speichern",
"saveAndAddAnother": "Speichern & nächsten Wert",
"savedToast": "Wert gespeichert",
@@ -8249,7 +8295,7 @@
"biomarkerPlaceholder": "Biomarker auswählen",
"defineNew": "+ Neuen Biomarker hinzufügen",
"noBiomarkersHint": "Lege zuerst einen Biomarker an, um einen Wert zu erfassen.",
"pickBiomarkerError": "Bitte zuerst einen Biomarker auswählen.",
"pickBiomarkerError": "Wähle zuerst einen Biomarker aus.",
"resultType": "Ergebnistyp",
"numeric": "Numerisch",
"qualitative": "Qualitativ",
@@ -8270,7 +8316,7 @@
"editDescription": "Wert, Datum oder Notiz dieser Messung korrigieren.",
"editedToast": "Wert aktualisiert",
"editError": "Wert konnte nicht aktualisiert werden",
"editTimestampError": "Bitte ein gültiges Datum eingeben.",
"editTimestampError": "Gib ein gültiges Datum ein.",
"editReading": "Wert bearbeiten",
"deleteReading": "Wert löschen",
"restoredToast": "Wert wiederhergestellt",
@@ -8388,36 +8434,36 @@
"bloodCount": "Blutbild"
},
"desc": {
"total-cholesterol": "Das Gesamtcholesterin ist die Summe des Cholesterins über alle Lipoprotein-Partikel im Blut, einschließlich der LDL-, HDL- und Remnant-Fraktion. Cholesterin selbst ist ein unverzichtbarer Baustein für Zellmembranen, Hormone und Gallensäuren. Als einzelner Wert bleibt es ein grobes Maß, denn sowohl ein hoher HDL- als auch ein hoher LDL-Anteil heben die Summe – deshalb liest man es meist gemeinsam mit den Einzelfraktionen. Erfasst wird es vor allem im Rahmen der langfristigen kardiovaskulären Risikoeinschätzung.",
"ldl": "Das LDL-Cholesterin ist das von Low-Density-Lipoprotein-Partikeln transportierte Cholesterin – die Fraktion, die Cholesterin zu den Geweben bringt, es aber auch in den Gefäßwänden ablagern kann. Über Jahre trägt diese Ablagerung zur arteriosklerotischen Plaque bei, weshalb LDL einer der am genauesten beobachteten Lipidmarker ist. Eine geringere langfristige Belastung geht in der Regel mit einem niedrigeren kardiovaskulären Risiko einher. Bewertet wird es nach Risikokategorie, nicht gegen einen festen Normwert.",
"total-cholesterol": "Das Gesamtcholesterin ist die Summe des Cholesterins über alle Lipoprotein-Partikel im Blut, einschließlich der LDL-, HDL- und Remnant-Fraktion. Cholesterin selbst ist ein unverzichtbarer Baustein für Zellmembranen, Hormone und Gallensäuren. Als einzelner Wert bleibt es ein grobes Maß, denn sowohl ein hoher HDL- als auch ein hoher LDL-Anteil heben die Summe — deshalb liest man es meist gemeinsam mit den Einzelfraktionen. Erfasst wird es vor allem im Rahmen der langfristigen kardiovaskulären Risikoeinschätzung.",
"ldl": "Das LDL-Cholesterin ist das von Low-Density-Lipoprotein-Partikeln transportierte Cholesterin — die Fraktion, die Cholesterin zu den Geweben bringt, es aber auch in den Gefäßwänden ablagern kann. Über Jahre trägt diese Ablagerung zur arteriosklerotischen Plaque bei, weshalb LDL einer der am genauesten beobachteten Lipidmarker ist. Eine geringere langfristige Belastung geht in der Regel mit einem niedrigeren kardiovaskulären Risiko einher. Bewertet wird es nach Risikokategorie, nicht gegen einen festen Normwert.",
"hdl": "Das HDL-Cholesterin ist das von High-Density-Lipoprotein-Partikeln transportierte Cholesterin, die überschüssiges Cholesterin aus den Geweben aufnehmen und zur Wiederverwertung oder Ausscheidung zur Leber zurückführen. Wegen dieser Transportfunktion wird es oft als die schützende Fraktion bezeichnet. Sehr niedrige Werte gehen tendenziell mit höherem kardiovaskulärem Risiko einher; ein künstliches Anheben hat das Risiko jedoch nicht zuverlässig gesenkt, weshalb man es als Marker und nicht als Zielgröße liest. In der Regel wird es zusammen mit LDL und Triglyzeriden beurteilt.",
"triglycerides": "Triglyzeride sind die wichtigste Form von Fett im Blut, die sowohl aus der Nahrung stammt als auch von der Leber gebildet wird, und sie dienen als zentraler Energiespeicher. Nach Mahlzeiten steigen die Werte, daher wird die Probe für die Vergleichbarkeit meist nüchtern entnommen. Dauerhaft hohe Triglyzeride werden mit metabolischem und kardiovaskulärem Risiko in Verbindung gebracht und treten häufig gemeinsam mit Insulinresistenz und niedrigem HDL auf. Sie werden als ein Bestandteil des gesamten Lipidbilds gelesen.",
"fasting-glucose": "Der Nüchternblutzucker ist die Zuckerkonzentration im Blut nach einer nächtlichen Nüchternphase und spiegelt wider, wie der Körper seinen Grundblutzucker ohne kürzliche Nahrungsaufnahme hält. Glukose ist der wichtigste unmittelbare Brennstoff des Körpers, und Insulin hält sie normalerweise in einem engen Bereich. Eine Nüchternmessung schließt den Effekt kürzlicher Mahlzeiten aus und ist damit ein Standardscreening dafür, wie die Glukoseregulation funktioniert. Er gehört zu den Markern, mit denen die Stoffwechselgesundheit über die Zeit beurteilt wird.",
"hba1c": "Der HbA1c ist der Anteil des Hämoglobins in den roten Blutkörperchen, der an Glukose gebunden ist und sich im Verhältnis zur durchschnittlichen Blutzuckerbelastung anreichert. Da rote Blutkörperchen etwa drei Monate leben, gibt er den durchschnittlichen Blutzucker der vorangegangenen zwei bis drei Monate wieder und nicht einen einzelnen Augenblick. Das macht ihn zu einem stabilen, mahlzeitenunabhängigen Maß der Blutzuckereinstellung. Seine Aussagekraft kann durch Erkrankungen beeinträchtigt sein, die die Lebensdauer der roten Blutkörperchen verändern.",
"tsh": "Das TSH (Thyreoidea-stimulierendes Hormon) wird von der Hirnanhangdrüse ausgeschüttet, um die Schilddrüse zur Hormonproduktion anzuregen, und es passt sich nach oben oder unten an, um diese im Gleichgewicht zu halten. Aufgrund dieses Regelkreises ist das TSH das empfindlichste Erstlinien-Screening der Schilddrüsenfunktion: Es bewegt sich meist, bevor die Schilddrüsenhormone selbst abweichen. Ein erhöhter oder erniedrigter Wert deutet auf eine Unter- oder Überfunktion hin und führt in der Regel zur direkten Bestimmung der Schilddrüsenhormone. Schwangerschaft und Alter verschieben den erwarteten Bereich.",
"ft3": "Das freie T3 (Trijodthyronin) ist die ungebundene, biologisch aktive Form des wirksameren Schilddrüsenhormons – der Anteil, der den Geweben zur Verfügung steht. Der größte Teil des zirkulierenden T3 ist an Trägerproteine gebunden und inaktiv, sodass der freie Anteil die tatsächliche Hormonverfügbarkeit widerspiegelt. Gemessen wird es zur Beurteilung der Schilddrüsenfunktion, besonders gemeinsam mit TSH und freiem T4, wenn Befunde unklar sind. Die Werte sind stark assayabhängig und werden im klinischen Zusammenhang gelesen.",
"ft3": "Das freie T3 (Trijodthyronin) ist die ungebundene, biologisch aktive Form des wirksameren Schilddrüsenhormons — der Anteil, der den Geweben zur Verfügung steht. Der größte Teil des zirkulierenden T3 ist an Trägerproteine gebunden und inaktiv, sodass der freie Anteil die tatsächliche Hormonverfügbarkeit widerspiegelt. Gemessen wird es zur Beurteilung der Schilddrüsenfunktion, besonders gemeinsam mit TSH und freiem T4, wenn Befunde unklar sind. Die Werte sind stark assayabhängig und werden im klinischen Zusammenhang gelesen.",
"ft4": "Das freie T4 (Thyroxin) ist die ungebundene Form des wichtigsten Hormons, das die Schilddrüse ins Blut abgibt; es dient als Reservoir, das die Gewebe bei Bedarf in das aktivere T3 umwandeln. Der freie Anteil spiegelt wider, was dem Körper tatsächlich zur Verfügung steht, unabhängig von der Menge der Trägerproteine. In der Regel wird es zusammen mit dem TSH bestimmt, um Probleme der Schilddrüse von Störungen der hypophysären Steuerung zu unterscheiden. Schwangerschaft und bestimmte Medikamente können es verschieben.",
"ferritin": "Ferritin ist das wichtigste Eisenspeicherprotein des Körpers, und sein Blutwert spiegelt grob die gesamten Eisenreserven wider. Ein niedriger Wert deutet auf erschöpfte Speicher hin, ein hoher Wert kann eine Eisenüberladung anzeigen – oder schlicht eine Entzündung, denn Ferritin steigt auch als Akute-Phase-Protein. Aus diesem Grund wird es oft zusammen mit anderen Eisenmarkern und Entzündungszeichen beurteilt. Es ist eines der zentralen Maße zur Einschätzung des Eisenstatus.",
"transferrin-saturation": "Die Transferrinsättigung ist der Prozentsatz des Transferrins – des Proteins, das Eisen durch das Blut transportiert –, der aktuell mit Eisen beladen ist. Sie verknüpft die Menge des zirkulierenden Eisens mit der Eisentransportkapazität und liefert damit eine Momentaufnahme der Eisenverfügbarkeit, die Ferritins Blick auf die Speicher ergänzt. Eine niedrige Sättigung deutet auf einen Eisenmangel hin, ein hoher Wert kann eine Überladung anzeigen. Sie wird zusammen mit Ferritin und dem gesamten Eisenpanel gelesen.",
"ferritin": "Ferritin ist das wichtigste Eisenspeicherprotein des Körpers, und sein Blutwert spiegelt grob die gesamten Eisenreserven wider. Ein niedriger Wert deutet auf erschöpfte Speicher hin, ein hoher Wert kann eine Eisenüberladung anzeigen — oder schlicht eine Entzündung, denn Ferritin steigt auch als Akute-Phase-Protein. Aus diesem Grund wird es oft zusammen mit anderen Eisenmarkern und Entzündungszeichen beurteilt. Es ist eines der zentralen Maße zur Einschätzung des Eisenstatus.",
"transferrin-saturation": "Die Transferrinsättigung ist der Prozentsatz des Transferrins — des Proteins, das Eisen durch das Blut transportiert –, der aktuell mit Eisen beladen ist. Sie verknüpft die Menge des zirkulierenden Eisens mit der Eisentransportkapazität und liefert damit eine Momentaufnahme der Eisenverfügbarkeit, die Ferritins Blick auf die Speicher ergänzt. Eine niedrige Sättigung deutet auf einen Eisenmangel hin, ein hoher Wert kann eine Überladung anzeigen. Sie wird zusammen mit Ferritin und dem gesamten Eisenpanel gelesen.",
"vitamin-d": "Vitamin D wird als 25-Hydroxy-Vitamin-D gemessen, der zirkulierenden Speicherform und dem Standardmarker des gesamten Vitamin-D-Status. Der Körper bildet es in der dem Sonnenlicht ausgesetzten Haut und nimmt einen Teil über die Nahrung auf; es unterstützt den Kalziumhaushalt, die Knochengesundheit und die Immunfunktion. Die Werte schwanken mit Jahreszeit, geografischer Breite, Hauttyp und Nahrungsergänzung. Erfasst wird es, um über die Zeit einzuschätzen, ob die Speicher ausreichend gefüllt sind.",
"vitamin-b12": "Vitamin B12 (Cobalamin) ist ein essenzielles Vitamin, das vor allem aus tierischen Lebensmitteln stammt und größtenteils in der Leber gespeichert wird. Es wird für die Bildung roter Blutkörperchen, die DNA-Synthese und die Gesundheit des Nervensystems benötigt. Ein länger anhaltender Mangel – durch geringe Zufuhr oder gestörte Aufnahme – kann zu einer Blutarmut und nervenbedingten Beschwerden führen. Gemessen wird es zur Beurteilung des Ernährungsstatus, insbesondere dort, wo Zufuhr oder Aufnahme eingeschränkt sein können.",
"vitamin-b12": "Vitamin B12 (Cobalamin) ist ein essenzielles Vitamin, das vor allem aus tierischen Lebensmitteln stammt und größtenteils in der Leber gespeichert wird. Es wird für die Bildung roter Blutkörperchen, die DNA-Synthese und die Gesundheit des Nervensystems benötigt. Ein länger anhaltender Mangel — durch geringe Zufuhr oder gestörte Aufnahme — kann zu einer Blutarmut und nervenbedingten Beschwerden führen. Gemessen wird es zur Beurteilung des Ernährungsstatus, insbesondere dort, wo Zufuhr oder Aufnahme eingeschränkt sein können.",
"folate": "Folat (Vitamin B9) ist ein B-Vitamin, das in grünem Blattgemüse, Hülsenfrüchten und angereicherten Lebensmitteln vorkommt und für die Bildung roter Blutkörperchen und die DNA-Synthese zentral ist. Es arbeitet eng mit Vitamin B12 zusammen, und ein Mangel an einem von beiden kann eine ähnliche Form der Blutarmut hervorrufen. Eine ausreichende Folatversorgung ist besonders rund um die frühe Schwangerschaft für die gesunde Entwicklung des Kindes wichtig. Gemessen wird es zur Beurteilung des Ernährungsstatus und der Ursache bestimmter Anämien.",
"hs-crp": "Das hochsensitive CRP misst das C-reaktive Protein, einen Marker, den die Leber als Reaktion auf Entzündungen ausschüttet, mit einem Verfahren, das auf die für das kardiovaskuläre Risiko relevanten niedrigen Konzentrationen abgestimmt ist. Anders als das Standard-CRP für akute Infektionen erfasst die hochsensitive Variante die feine, chronische Entzündung, die mit Gefäßerkrankungen verbunden ist. Da jede kürzliche Infektion oder Verletzung den Wert anhebt, wird er außerhalb akuter Erkrankung beurteilt und oft mit einer Wiederholungsprobe bestätigt. Er wird als ein Beitrag zum gesamten kardiovaskulären Risiko gelesen.",
"creatinine": "Kreatinin ist ein Abfallprodukt des normalen Muskelstoffwechsels, das die Nieren mit recht gleichbleibender Rate aus dem Blut filtern. Da die Produktion verhältnismäßig konstant ist, dient der Blutwert als praktisches Maß dafür, wie gut die Nieren Abfallstoffe ausscheiden. Die Werte werden auch von Muskelmasse, Flüssigkeitshaushalt und Ernährung beeinflusst, weshalb sie meist gemeinsam mit der geschätzten Filtrationsrate beurteilt werden. Es ist ein zentraler Marker der Nierenfunktion.",
"egfr": "Die eGFR (geschätzte glomeruläre Filtrationsrate) schätzt, wie viel Blut die Nieren pro Minute filtern, berechnet aus dem Kreatinin zusammen mit Alter und Geschlecht. Sie übersetzt einen rohen Kreatininwert in ein unmittelbarer verständliches Maß der Nierenfunktion. Eine über die Zeit sinkende eGFR ist der wichtigste Weg, um chronische Nierenveränderungen einzustufen und zu verfolgen. Als Schätzwert wird sie als Trend und nicht als einzelner exakter Zahlenwert gelesen.",
"alt": "Die ALT (Alanin-Aminotransferase) ist ein vorwiegend in den Leberzellen konzentriertes Enzym, das ins Blut freigesetzt wird, wenn diese Zellen belastet oder geschädigt sind. Da sie verhältnismäßig leberspezifisch ist, gehört sie zu den wichtigsten Markern, um eine Leberschädigung oder -entzündung zu erkennen. Die Werte können bei Fettleber, Virushepatitis, Alkohol oder bestimmten Medikamenten steigen. In der Regel wird sie zusammen mit der AST und den übrigen Markern des Leberpanels gelesen.",
"ast": "Die AST (Aspartat-Aminotransferase) ist ein Enzym, das in der Leber, aber auch in Muskel, Herz und roten Blutkörperchen vorkommt und bei einer Schädigung dieser Gewebe freigesetzt wird. Da sie weniger leberspezifisch ist als die ALT, werden beide gemeinsam beurteilt – ihr Verhältnis kann Hinweise auf Quelle und Art des Problems geben. Intensive körperliche Belastung oder eine Muskelverletzung können sie unabhängig von der Leber anheben. Sie ist eines der Standardenzyme des Leberpanels.",
"ggt": "Die GGT (Gamma-Glutamyltransferase) ist ein Enzym in der Leber und den Gallenwegen, das besonders bei einem gestörten Gallenfluss oder bei Alkoholexposition ansteigt. Häufig wird sie genutzt, um zu klären, warum ein anderer Lebermarker erhöht ist – etwa um eine Quelle in der Leber oder den Gallenwegen zu unterscheiden. Sie ist empfindlich, aber wenig spezifisch und wird daher im Zusammenhang mit dem übrigen Leberpanel gelesen. Sie ist einer von mehreren Markern zur Beurteilung der Leber- und Gallengesundheit.",
"ast": "Die AST (Aspartat-Aminotransferase) ist ein Enzym, das in der Leber, aber auch in Muskel, Herz und roten Blutkörperchen vorkommt und bei einer Schädigung dieser Gewebe freigesetzt wird. Da sie weniger leberspezifisch ist als die ALT, werden beide gemeinsam beurteilt — ihr Verhältnis kann Hinweise auf Quelle und Art des Problems geben. Intensive körperliche Belastung oder eine Muskelverletzung können sie unabhängig von der Leber anheben. Sie ist eines der Standardenzyme des Leberpanels.",
"ggt": "Die GGT (Gamma-Glutamyltransferase) ist ein Enzym in der Leber und den Gallenwegen, das besonders bei einem gestörten Gallenfluss oder bei Alkoholexposition ansteigt. Häufig wird sie genutzt, um zu klären, warum ein anderer Lebermarker erhöht ist — etwa um eine Quelle in der Leber oder den Gallenwegen zu unterscheiden. Sie ist empfindlich, aber wenig spezifisch und wird daher im Zusammenhang mit dem übrigen Leberpanel gelesen. Sie ist einer von mehreren Markern zur Beurteilung der Leber- und Gallengesundheit.",
"sodium": "Natrium ist der wichtigste Elektrolyt in der Flüssigkeit außerhalb der Körperzellen und der zentrale Regler von Flüssigkeitshaushalt und Blutvolumen. Der Körper steuert es eng über Durst, Hormone und die Nieren, sodass der Blutwert eher das Gleichgewicht zwischen Wasser und Salz widerspiegelt als die Zufuhr allein. Abweichende Werte signalisieren meist eine Störung des Wasserhaushalts und nicht des Natriums selbst. Es ist ein Kernbestandteil des grundlegenden Stoffwechsel- und Elektrolytpanels.",
"potassium": "Kalium ist der wichtigste Elektrolyt innerhalb der Körperzellen und für die Nervenleitung und den gleichmäßigen Rhythmus des Herzmuskels unverzichtbar. Sein Blutwert wird durch die Nieren und hormonelle Signale in einem engen Bereich gehalten, da sowohl hohe als auch niedrige Werte die elektrische Aktivität des Herzens stören können. Die Werte können durch Nierenfunktion, Flüssigkeitshaushalt und mehrere gängige Medikamente beeinflusst werden. Es ist ein Kernmaß des Elektrolytpanels.",
"hemoglobin": "Hämoglobin ist das eisenhaltige Protein in den roten Blutkörperchen, das Sauerstoff in der Lunge bindet und zu den Geweben transportiert. Seine Konzentration ist das wichtigste Maß für die Sauerstofftransportkapazität des Blutes und der zentrale Marker zum Erkennen einer Blutarmut. Niedrige Werte können auf Blutverlust, Eisen- oder Vitaminmangel oder eine verringerte Bildung hinweisen, hohe Werte können auf Flüssigkeitsmangel oder sauerstoffarme Bedingungen folgen. Es ist ein Eckpfeiler des großen Blutbilds.",
"hematocrit": "Der Hämatokrit ist der Anteil der roten Blutkörperchen am Blutvolumen, ausgedrückt als Prozentwert. Er bewegt sich eng mit dem Hämoglobin und gibt einen ergänzenden Blick auf die Erythrozytenmasse und die Sauerstofftransportkapazität des Blutes. Der Flüssigkeitshaushalt beeinflusst ihn, da Veränderungen des Plasmavolumens den Anteil verschieben, selbst wenn die Zahl der roten Blutkörperchen stabil bleibt. Er wird zusammen mit dem Hämoglobin und dem übrigen Blutbild gelesen.",
"wbc": "Die Leukozytenzahl ist die Gesamtzahl der im Blut zirkulierenden Immunzellen, der mobilen Abwehr des Körpers gegen Infektionen. Sie steigt als Reaktion auf Infektion, Entzündung oder Stress und kann fallen, wenn das Knochenmark unterdrückt ist oder bei manchen Immunerkrankungen. Da sie mehrere unterschiedliche Zelltypen zusammenfasst, wird eine deutliche Veränderung oft durch ein Differenzialblutbild aufgeschlüsselt. Sie ist ein Kernbestandteil des großen Blutbilds.",
"platelets": "Thrombozyten sind kleine Zellfragmente im Blut, die sich zusammenlagern und den ersten Pfropf bilden, der eine Blutung aus einem geschädigten Gefäß stoppt. Ihre Zahl spiegelt das Gleichgewicht zwischen Bildung im Knochenmark und Verbrauch oder Abbau wider. Eine niedrige Zahl kann die Blutungsneigung erhöhen, eine hohe Zahl kann auf eine Entzündung oder andere Reize folgen. Sie sind Teil des großen Blutbilds und ein grundlegendes Maß der Gerinnungsfähigkeit.",
"apob": "Apolipoprotein B ist das Strukturprotein, das jedes der atherogenen Lipoprotein-Partikel trägt; seine Messung zählt daher diese Partikel direkt und nicht das von ihnen transportierte Cholesterin. Da auf jedem LDL-, VLDL- und verwandten Partikel genau ein ApoB-Molekül sitzt, kann es kardiovaskuläres Risiko erfassen, das ein LDL-Cholesterinwert übersieht – besonders wenn Partikelzahl und Cholesteringehalt auseinandergehen. Aus diesem Grund wird es zunehmend genutzt, um die lipidbasierte Risikoeinschätzung zu verfeinern. Beurteilt wird es zusammen mit dem üblichen Lipidpanel.",
"apob": "Apolipoprotein B ist das Strukturprotein, das jedes der atherogenen Lipoprotein-Partikel trägt; seine Messung zählt daher diese Partikel direkt und nicht das von ihnen transportierte Cholesterin. Da auf jedem LDL-, VLDL- und verwandten Partikel genau ein ApoB-Molekül sitzt, kann es kardiovaskuläres Risiko erfassen, das ein LDL-Cholesterinwert übersieht — besonders wenn Partikelzahl und Cholesteringehalt auseinandergehen. Aus diesem Grund wird es zunehmend genutzt, um die lipidbasierte Risikoeinschätzung zu verfeinern. Beurteilt wird es zusammen mit dem üblichen Lipidpanel.",
"lp-a": "Lipoprotein(a) ist ein LDL-ähnliches Partikel mit einem zusätzlich angehefteten Protein, dessen Spiegel weitgehend genetisch festgelegt ist und ein Leben lang recht stabil bleibt. Ein erhöhtes Lp(a) ist ein unabhängiger, vererbter Risikofaktor für Herz-Kreislauf-Erkrankungen und eine Verengung der Aortenklappe. Da es sich kaum verändert, wird es meist einmal bestimmt, um das lebenslange Risiko einer Person festzulegen, statt es wiederholt zu verfolgen. Es wird in molaren Einheiten angegeben und als Teil des gesamten kardiovaskulären Risikos gelesen.",
"fasting-insulin": "Das Nüchterninsulin ist der Spiegel des Insulins – des Hormons, das Glukose aus dem Blut in die Zellen schleust –, gemessen nach einer nächtlichen Nüchternphase. Zusammen mit dem Nüchternblutzucker hilft es, die Insulinresistenz abzuschätzen, also den Zustand, in dem der Körper mehr Insulin braucht, um den Blutzucker im Bereich zu halten. Ein hohes Nüchterninsulin bei normalem Blutzucker kann ein frühes Zeichen dafür sein, dass dieser Ausgleich im Gange ist. Gelesen wird es als Teil eines Stoffwechselbilds und nicht für sich allein.",
"omega-3-index": "Der Omega-3-Index ist der Anteil von EPA und DHA – den beiden langkettigen marinen Omega-3-Fettsäuren – an den Fettsäuren in den Membranen der roten Blutkörperchen. Da rote Blutkörperchen sich langsam erneuern, spiegelt er die langfristige Omega-3-Zufuhr wider und nicht eine kürzliche Mahlzeit. Ein höherer Index wurde in Beobachtungsstudien mit einem niedrigeren kardiovaskulären Risiko in Verbindung gebracht. Er wird als stabiler Marker des Omega-3-Status verfolgt, der über Monate auf die Ernährung reagiert."
"fasting-insulin": "Das Nüchterninsulin ist der Spiegel des Insulins — des Hormons, das Glukose aus dem Blut in die Zellen schleust –, gemessen nach einer nächtlichen Nüchternphase. Zusammen mit dem Nüchternblutzucker hilft es, die Insulinresistenz abzuschätzen, also den Zustand, in dem der Körper mehr Insulin braucht, um den Blutzucker im Bereich zu halten. Ein hohes Nüchterninsulin bei normalem Blutzucker kann ein frühes Zeichen dafür sein, dass dieser Ausgleich im Gange ist. Gelesen wird es als Teil eines Stoffwechselbilds und nicht für sich allein.",
"omega-3-index": "Der Omega-3-Index ist der Anteil von EPA und DHA — den beiden langkettigen marinen Omega-3-Fettsäuren — an den Fettsäuren in den Membranen der roten Blutkörperchen. Da rote Blutkörperchen sich langsam erneuern, spiegelt er die langfristige Omega-3-Zufuhr wider und nicht eine kürzliche Mahlzeit. Ein höherer Index wurde in Beobachtungsstudien mit einem niedrigeren kardiovaskulären Risiko in Verbindung gebracht. Er wird als stabiler Marker des Omega-3-Status verfolgt, der über Monate auf die Ernährung reagiert."
},
"apob": "Apolipoprotein B (ApoB)",
"lp-a": "Lipoprotein(a)",
@@ -8435,7 +8481,7 @@
"acceptImageOnly": "JPEG, PNG oder WebP",
"extracting": "Befund wird gelesen…",
"readingOnDevice": "Wird auf deinem Gerät gelesen…",
"localModeHint": "Wird auf deinem Gerät gelesen und als Text gesendet. Weniger genau – prüfe jeden Wert.",
"localModeHint": "Wird auf deinem Gerät gelesen und als Text gesendet. Weniger genau — prüfe jeden Wert.",
"rangeFromScan": "Referenzbereich aus dem Scan übernommen",
"rangeVerify": "Referenzbereich prüfen",
"reviewTitle": "Vor dem Speichern prüfen",
@@ -8452,7 +8498,7 @@
"newBiomarker": "Neuer Biomarker — wird angelegt",
"linksExisting": "Verknüpft mit vorhandenem {name}",
"duplicateWarning": "Ein passender Wert existiert bereits",
"lowConfidence": "Bitte diesen Wert überprüfen",
"lowConfidence": "Prüfe diesen Wert",
"lowConfidenceBadge": "Geringe Sicherheit",
"valueUnreadable": "Wert unleserlich — bitte manuell eingeben",
"saveSelected": "{count} ausgewählte speichern",
@@ -8807,7 +8853,7 @@
"countOne": "Du hast in diesem Zeitraum 1 Episode erfasst.",
"countOther": "Du hast in diesem Zeitraum {count} Episoden erfasst.",
"typicalGap": "Typische Erholungslücke: {days} Tage zwischen Besserung und normalisierten Werten.",
"typicalGapWithDriver": "Typische Erholungslücke: {days} Tage – du fühlst dich meist besser, bevor sich {vital} normalisiert.",
"typicalGapWithDriver": "Typische Erholungslücke: {days} Tage — du fühlst dich meist besser, bevor sich {vital} normalisiert.",
"typicalGapLearning": "Noch ein paar abgeschlossene Episoden und hier erscheint eine typische Erholungslücke.",
"none": "In diesem Zeitraum keine Episoden erfasst.",
"analyze": "Erholungslücke analysieren"
@@ -8868,8 +8914,8 @@
"stop": "Ich messe genug",
"accepted": "Erinnerung gesetzt",
"dismissed": "Okay, das schlage ich nicht noch einmal vor",
"stopped": "Verstanden – ich schlage keine Erinnerungen mehr vor",
"failed": "Hat nicht geklappt – bitte erneut versuchen",
"stopped": "Verstanden — ich schlage keine Erinnerungen mehr vor",
"failed": "Hat nicht geklappt — bitte erneut versuchen",
"cadence": {
"weightDaily": "Wieg dich täglich, ungefähr zur selben Zeit",
"bp722": "Miss deinen Blutdruck eine Woche lang zweimal täglich",
@@ -9035,7 +9081,9 @@
"flaggedBadge": "Hilfe angezeigt",
"chartTitle": "Punkteverlauf",
"totalLabel": "Punkte",
"bandLabel": "Einstufung"
"bandLabel": "Einstufung",
"answersToggle": "Antworten",
"answersUnavailable": "Die gespeicherten Antworten dieses Check-ins können nicht entschlüsselt werden."
},
"crisis": {
"title": "Du musst das nicht allein durchstehen",
@@ -9739,6 +9787,8 @@
"editedToast": "Wert aktualisiert",
"editError": "Wert konnte nicht aktualisiert werden",
"deletedToast": "Wert gelöscht",
"restoredToast": "Messwert wiederhergestellt",
"restoreError": "Wiederherstellen fehlgeschlagen",
"deleteError": "Wert konnte nicht gelöscht werden"
},
"chart": {
@@ -9911,7 +9961,7 @@
"scoreLabel": "Gesundheitsscore",
"deltaVsBaseline": "{delta} ggü. deinem Ausgangswert",
"deltaFlat": "Im Bereich deines Ausgangswerts",
"sleepPending": "Der Schlaf von letzter Nacht fehlt noch – der heutige Wert ist vorläufig.",
"sleepPending": "Der Schlaf von letzter Nacht fehlt noch — der heutige Wert ist vorläufig.",
"worthALook": "Einen Blick wert",
"allClear": "Heute braucht nichts deine Aufmerksamkeit.",
"ringLink": "{metric}-Details öffnen"
@@ -10071,7 +10121,7 @@
"errorNoAccount": "Auf dieser Instanz gibt es kein Konto mit diesem Namen.",
"errorDuplicate": "Dieses Konto hat bereits Zugriff auf deine Akte.",
"errorSelf": "Das ist dein eigenes Konto.",
"errorInvalid": "Bitte einen Benutzernamen oder eine E-Mail-Adresse eingeben.",
"errorInvalid": "Gib einen Benutzernamen oder eine E-Mail-Adresse ein.",
"errorRateLimited": "Zu viele Einladungen. Bitte später noch einmal versuchen.",
"errorFailed": "Die Einladung konnte nicht gesendet werden. Bitte noch einmal versuchen.",
"errorScope": "So lassen sich diese Bereiche nicht teilen. Wähle mindestens einen Bereich aus oder teile die gesamte Akte.",
@@ -10208,6 +10258,8 @@
"measurementReminderDelete": "{name} hat eine Messerinnerung gelöscht",
"measurementReminderComplete": "{name} hat eine Messerinnerung erledigt",
"measurementReminderSatisfy": "{name} hat eine Messerinnerung abgehakt",
"measurementReminderSkip": "{name} hat eine Messerinnerung übersprungen",
"measurementReminderSnooze": "{name} hat eine Messerinnerung verschoben",
"labResultUpdate": "{name} hat einen Laborwert geändert",
"labResultDelete": "{name} hat einen Laborwert gelöscht",
"labResultRestore": "{name} hat einen Laborwert wiederhergestellt",
+57 -5
View File
@@ -516,6 +516,9 @@
"heroItemsTitle": "Today highlights",
"heroItemsDescription": "Choose which item types can appear in Today highlights.",
"heroItemsNotificationNote": "Notification settings are managed separately.",
"heroContentTitle": "Top card shows",
"heroContentScore": "Health score and daily summary",
"heroContentReminders": "Today highlights",
"heroItems": {
"coach_checkin": "Coach check-ins",
"dose_window": "Medication doses",
@@ -3168,7 +3171,8 @@
"sleep": {
"title": "Sleep",
"description": "Stage breakdown and nightly duration, drawn from Apple Health.",
"headlineTitle": "Average per night",
"mainSleep": "Main sleep",
"totalSleep": "Total sleep",
"compositionTitle": "Stage composition",
"compositionSubtitle": "Last {nights} nights",
"compositionAriaLabel": "Sleep stage composition over {nights} nights",
@@ -4517,7 +4521,7 @@
"title": "Withings",
"body": "Scales, blood-pressure cuffs and trackers from Withings sync automatically once connected.",
"cta": "Connect Withings",
"setupCta": "Set up in Settings",
"setupCta": "Set up",
"setupHint": "Needs your own free Withings developer app first — about 5 minutes, one time."
},
"more": {
@@ -4528,7 +4532,8 @@
"oura": "Oura",
"polar": "Polar",
"nightscout": "Nightscout",
"fitbit": "Fitbit"
"fitbit": "Fitbit",
"appleHealthHint": "Apple Health has no web connection — finish onboarding here, then open the HealthLog app on your iPhone to complete Apple Health setup."
},
"import": "Import existing history"
},
@@ -5967,7 +5972,7 @@
},
"notificationStatus": {
"title": "Delivery status",
"description": "Per-channel state; \"Auto-disabled\" stops retries after permanent errors.",
"description": "State of each channel — “Auto-disabled” means retries stopped after permanent errors.",
"emptyDescription": "Set up a channel under Settings › Integrations › Delivery channels.",
"stateActive": "Active",
"stateAutoDisabled": "Auto-disabled",
@@ -6387,6 +6392,13 @@
"importNote": "Need older history or cannot use the iOS app? The Apple Health XML/ZIP upload is a one-shot fallback and does not enable live sync.",
"importAction": "Open one-shot import",
"staleNote": "No Apple Health data has arrived for over a week. Open the HealthLog app on your iPhone and check the Apple Health permissions.",
"progress": {
"title": "Sync progress",
"receivedLabel": "Records received",
"oldestLabel": "Oldest reading reached",
"flowing": "Data arrived within the last hour — the iPhone app is still delivering.",
"waiting": "Waiting for the iPhone app to send more data. Delivery continues when the app is open or when iOS grants it background time — a quiet stretch during the first big backfill is normal, not an error."
},
"delivery": {
"title": "Delivery",
"lastTriggerLabel": "Latest sync arrived",
@@ -6891,6 +6903,23 @@
"restoreTitle": "Restore from backup",
"restoreDescription": "This will replace ALL current data for {username} with the snapshot from {when}. Existing measurements, medications, intake events, mood entries, notification channels, push subscriptions and queued Telegram deletions will be deleted. If included in the snapshot, instance-wide settings overwrite the configuration for the entire installation.",
"restorePromptLabel": "Type RESTORE to continue",
"previewTitle": "What this backup contains",
"previewLoading": "Reading contents…",
"previewUnavailable": "Contents could not be read — restoring is still possible.",
"previewEmpty": "The file contains no records.",
"previewOther": "Other records",
"previewMeasurements": "Measurements",
"previewMedications": "Medications and intakes",
"previewMood": "Mood entries",
"previewLabs": "Lab results",
"previewDocuments": "Documents",
"previewWorkouts": "Workouts",
"previewCycles": "Cycle data",
"previewVisits": "Doctor visits",
"previewVaccinations": "Vaccinations",
"previewReminders": "Preventive-care reminders",
"previewCustomMetrics": "Custom metrics",
"previewHealthScores": "Score history",
"restoreConfirm": "Restore now",
"restoreInProgress": "Restoring…",
"restoreSuccess": "Restore complete",
@@ -8085,6 +8114,23 @@
"delete": "Delete",
"deleteConfirmTitle": "Delete reminder?",
"deleteConfirmDescription": "This checkup reminder will be permanently deleted.",
"postpone": {
"menuItem": "Postpone…",
"title": "Postpone due date",
"description": "Only moves the current due date — the regular rhythm stays unchanged.",
"chip": "+{days} days",
"dateLabel": "New date",
"confirm": "Postpone"
},
"skip": {
"heading": "Skip this due date",
"explainer": "Recorded honestly as skipped — not as done. The interval restarts from today.",
"action": "Skip",
"confirmTitle": "Skip this due date?"
},
"snoozedUntil": "Postponed until {date}",
"skippedOn": "Skipped on {date}",
"skipCount": "skipped {count}×",
"disabledBadge": "Off",
"nextDue": {
"none": "Not scheduled",
@@ -9035,7 +9081,9 @@
"flaggedBadge": "Support shown",
"chartTitle": "Score trend",
"totalLabel": "Score",
"bandLabel": "Category"
"bandLabel": "Category",
"answersToggle": "Answers",
"answersUnavailable": "The stored answers for this check-in cannot be decrypted."
},
"crisis": {
"title": "You don't have to face this alone",
@@ -9739,6 +9787,8 @@
"editedToast": "Value updated",
"editError": "Could not update the value",
"deletedToast": "Value deleted",
"restoredToast": "Value restored",
"restoreError": "Restore failed",
"deleteError": "Could not delete the value"
},
"chart": {
@@ -10208,6 +10258,8 @@
"measurementReminderDelete": "{name} removed a measurement reminder",
"measurementReminderComplete": "{name} completed a measurement reminder",
"measurementReminderSatisfy": "{name} cleared a measurement reminder",
"measurementReminderSkip": "{name} skipped a measurement reminder",
"measurementReminderSnooze": "{name} postponed a measurement reminder",
"labResultUpdate": "{name} changed a lab result",
"labResultDelete": "{name} removed a lab result",
"labResultRestore": "{name} restored a lab result",
+57 -5
View File
@@ -516,6 +516,9 @@
"heroItemsTitle": "Destacados de hoy",
"heroItemsDescription": "Elige qué tipos de elementos pueden aparecer en los destacados de hoy.",
"heroItemsNotificationNote": "Los ajustes de notificaciones se gestionan por separado.",
"heroContentTitle": "La tarjeta superior muestra",
"heroContentScore": "Puntuación de salud y resumen del día",
"heroContentReminders": "Destacados de hoy",
"heroItems": {
"coach_checkin": "Revisión con Coach",
"dose_window": "Dosis de medicamentos",
@@ -3168,7 +3171,8 @@
"sleep": {
"title": "Sueño",
"description": "Desglose por fases y duración nocturna, tomados de Apple Health.",
"headlineTitle": "Media por noche",
"mainSleep": "Sueño principal",
"totalSleep": "Sueño total",
"compositionTitle": "Distribución de fases",
"compositionSubtitle": "Últimas {nights} noches",
"compositionAriaLabel": "Distribución de fases del sueño en {nights} noches",
@@ -4517,7 +4521,7 @@
"title": "Withings",
"body": "Las básculas, los tensiómetros y los trackers de Withings se sincronizan automáticamente una vez conectados.",
"cta": "Conectar Withings",
"setupCta": "Configurar en Ajustes",
"setupCta": "Configurar",
"setupHint": "Necesita primero tu propia app de desarrollador de Withings, gratuita — unos 5 minutos, una sola vez."
},
"more": {
@@ -4528,7 +4532,8 @@
"oura": "Oura",
"polar": "Polar",
"nightscout": "Nightscout",
"fitbit": "Fitbit"
"fitbit": "Fitbit",
"appleHealthHint": "Apple Health no tiene conexión web — termina aquí la puesta en marcha y luego abre la app HealthLog en tu iPhone para completar la configuración de Apple Health."
},
"import": "Importar historial existente"
},
@@ -5967,7 +5972,7 @@
},
"notificationStatus": {
"title": "Fiabilidad de los canales",
"description": "Estado por canal; «Desactivado automáticamente» detiene los reintentos tras errores permanentes.",
"description": "Estado de cada canal — «Desactivado automáticamente» significa detenido tras errores permanentes.",
"emptyDescription": "Configura un canal en Ajustes › Integraciones › Canales de entrega.",
"stateActive": "Activo",
"stateAutoDisabled": "Desactivado automáticamente",
@@ -6387,6 +6392,13 @@
"importNote": "¿Necesitas un historial anterior o no puedes usar la app de iOS? La carga XML/ZIP de Apple Health es una alternativa puntual y no activa la sincronización en vivo.",
"importAction": "Abrir importación puntual",
"staleNote": "Hace más de una semana que no llegan datos de Apple Health. Abre la app HealthLog en tu iPhone y revisa los permisos de Apple Health.",
"progress": {
"title": "Progreso de sincronización",
"receivedLabel": "Registros recibidos",
"oldestLabel": "Lectura más antigua alcanzada",
"flowing": "Han llegado datos en la última hora — la app del iPhone sigue enviando.",
"waiting": "Esperando a que la app del iPhone envíe más datos. La transferencia continúa cuando la app está abierta o cuando iOS le concede tiempo en segundo plano — una pausa durante la primera sincronización grande es normal, no un error."
},
"delivery": {
"title": "Entrega",
"lastTriggerLabel": "La última sincronización llegó",
@@ -6891,6 +6903,23 @@
"restoreTitle": "Restaurar desde copia de seguridad",
"restoreDescription": "Esto reemplazará TODOS los datos actuales de {username} con la instantánea del {when}. Se eliminarán las mediciones, los medicamentos, los eventos de toma, las entradas de ánimo, los canales de notificación, las suscripciones push y las eliminaciones de Telegram en cola existentes.",
"restorePromptLabel": "Escribe RESTORE para continuar",
"previewTitle": "Contenido de la copia",
"previewLoading": "Leyendo el contenido…",
"previewUnavailable": "No se pudo leer el contenido — restaurar sigue siendo posible.",
"previewEmpty": "El archivo no contiene registros.",
"previewOther": "Otros registros",
"previewMeasurements": "Mediciones",
"previewMedications": "Medicamentos y tomas",
"previewMood": "Registros de ánimo",
"previewLabs": "Resultados de laboratorio",
"previewDocuments": "Documentos",
"previewWorkouts": "Entrenamientos",
"previewCycles": "Datos del ciclo",
"previewVisits": "Visitas médicas",
"previewVaccinations": "Vacunas",
"previewReminders": "Recordatorios preventivos",
"previewCustomMetrics": "Métricas propias",
"previewHealthScores": "Historial de puntuación",
"restoreConfirm": "Restaurar ahora",
"restoreInProgress": "Restaurando...",
"restoreSuccess": "Restauración completada",
@@ -8085,6 +8114,23 @@
"delete": "Eliminar",
"deleteConfirmTitle": "¿Eliminar recordatorio?",
"deleteConfirmDescription": "Este recordatorio de revisión se eliminará de forma permanente.",
"postpone": {
"menuItem": "Aplazar…",
"title": "Aplazar vencimiento",
"description": "Solo mueve el vencimiento actual — el ritmo regular no cambia.",
"chip": "+{days} días",
"dateLabel": "Nueva fecha",
"confirm": "Aplazar"
},
"skip": {
"heading": "Omitir este vencimiento",
"explainer": "Se registra honestamente como omitido — no como hecho. El intervalo se reinicia desde hoy.",
"action": "Omitir",
"confirmTitle": "¿Omitir este vencimiento?"
},
"snoozedUntil": "Aplazado hasta {date}",
"skippedOn": "Omitido el {date}",
"skipCount": "omitido {count} veces",
"disabledBadge": "Apagado",
"nextDue": {
"none": "Sin programar",
@@ -9035,7 +9081,9 @@
"flaggedBadge": "Apoyo mostrado",
"chartTitle": "Evolución de la puntuación",
"totalLabel": "Puntuación",
"bandLabel": "Categoría"
"bandLabel": "Categoría",
"answersToggle": "Respuestas",
"answersUnavailable": "Las respuestas guardadas de esta revisión no se pueden descifrar."
},
"crisis": {
"title": "No tienes que afrontar esto a solas",
@@ -9739,6 +9787,8 @@
"editedToast": "Valor actualizado",
"editError": "No se pudo actualizar el valor",
"deletedToast": "Valor eliminado",
"restoredToast": "Valor restaurado",
"restoreError": "No se pudo restaurar",
"deleteError": "No se pudo eliminar el valor"
},
"chart": {
@@ -10208,6 +10258,8 @@
"measurementReminderDelete": "{name} eliminó un recordatorio de medición",
"measurementReminderComplete": "{name} completó un recordatorio de medición",
"measurementReminderSatisfy": "{name} cerró un recordatorio de medición",
"measurementReminderSkip": "{name} omitió un recordatorio de medición",
"measurementReminderSnooze": "{name} aplazó un recordatorio de medición",
"labResultUpdate": "{name} cambió un resultado de laboratorio",
"labResultDelete": "{name} eliminó un resultado de laboratorio",
"labResultRestore": "{name} restauró un resultado de laboratorio",
+57 -5
View File
@@ -516,6 +516,9 @@
"heroItemsTitle": "Temps forts du jour",
"heroItemsDescription": "Choisis les types d’éléments qui peuvent apparaître dans les temps forts du jour.",
"heroItemsNotificationNote": "Les réglages des notifications sont gérés séparément.",
"heroContentTitle": "La carte du haut affiche",
"heroContentScore": "Score de santé et résumé du jour",
"heroContentReminders": "Temps forts du jour",
"heroItems": {
"coach_checkin": "Bilan avec Coach",
"dose_window": "Doses de médicaments",
@@ -3168,7 +3171,8 @@
"sleep": {
"title": "Sommeil",
"description": "Répartition des phases et durée nocturne, issues d’Apple Health.",
"headlineTitle": "Moyenne par nuit",
"mainSleep": "Sommeil principal",
"totalSleep": "Sommeil total",
"compositionTitle": "Répartition des phases",
"compositionSubtitle": "{nights} dernières nuits",
"compositionAriaLabel": "Répartition des phases de sommeil sur {nights} nuits",
@@ -4517,7 +4521,7 @@
"title": "Withings",
"body": "Balances, tensiomètres et trackers Withings se synchronisent automatiquement une fois connectés.",
"cta": "Connecter Withings",
"setupCta": "Configurer dans les Réglages",
"setupCta": "Configurer",
"setupHint": "Nécessite d'abord votre propre application développeur Withings, gratuite — environ 5 minutes, une seule fois."
},
"more": {
@@ -4528,7 +4532,8 @@
"oura": "Oura",
"polar": "Polar",
"nightscout": "Nightscout",
"fitbit": "Fitbit"
"fitbit": "Fitbit",
"appleHealthHint": "Apple Health n'a pas de connexion web — terminez d'abord la configuration ici, puis ouvrez l'app HealthLog sur votre iPhone pour finaliser la configuration d'Apple Health."
},
"import": "Importer un historique existant"
},
@@ -5967,7 +5972,7 @@
},
"notificationStatus": {
"title": "Fiabilité des canaux",
"description": "État par canal ; « Désactivé automatiquement » arrête les nouvelles tentatives après des erreurs permanentes.",
"description": "État de chaque canal — « Désactivé automatiquement » signifie arrêté après des erreurs permanentes.",
"emptyDescription": "Ajoutez un canal dans Paramètres › Intégrations › Canaux de diffusion.",
"stateActive": "Actif",
"stateAutoDisabled": "Désactivé automatiquement",
@@ -6387,6 +6392,13 @@
"importNote": "Besoin d’un ancien historique ou impossible d’utiliser l’app iOS ? L’envoi XML/ZIP Apple Health est une solution ponctuelle et n’active pas la synchronisation en direct.",
"importAction": "Ouvrir l’import ponctuel",
"staleNote": "Aucune donnée Apple Health n'est arrivée depuis plus d'une semaine. Ouvrez l'app HealthLog sur votre iPhone et vérifiez les autorisations Apple Health.",
"progress": {
"title": "Progression de la synchronisation",
"receivedLabel": "Enregistrements reçus",
"oldestLabel": "Mesure la plus ancienne atteinte",
"flowing": "Des données sont arrivées au cours de la dernière heure — l'app iPhone continue d'envoyer.",
"waiting": "En attente de nouvelles données de l'app iPhone. Le transfert reprend quand l'app est ouverte ou quand iOS lui accorde du temps en arrière-plan — une accalmie pendant le premier grand rattrapage est normale, pas une erreur."
},
"delivery": {
"title": "Livraison",
"lastTriggerLabel": "La dernière synchronisation est arrivée",
@@ -6891,6 +6903,23 @@
"restoreTitle": "Restaurer à partir d'une sauvegarde",
"restoreDescription": "Cette action remplacera TOUTES les données actuelles de {username} par l'instantané du {when}. Les mesures, médicaments, événements de prise, entrées d'humeur, canaux de notification, abonnements push et suppressions Telegram en file d'attente existants seront supprimés.",
"restorePromptLabel": "Tapez RESTORE pour continuer",
"previewTitle": "Contenu de la sauvegarde",
"previewLoading": "Lecture du contenu…",
"previewUnavailable": "Le contenu n'a pas pu être lu — la restauration reste possible.",
"previewEmpty": "Le fichier ne contient aucun enregistrement.",
"previewOther": "Autres enregistrements",
"previewMeasurements": "Mesures",
"previewMedications": "Médicaments et prises",
"previewMood": "Entrées d'humeur",
"previewLabs": "Résultats de laboratoire",
"previewDocuments": "Documents",
"previewWorkouts": "Entraînements",
"previewCycles": "Données de cycle",
"previewVisits": "Visites médicales",
"previewVaccinations": "Vaccinations",
"previewReminders": "Rappels de prévention",
"previewCustomMetrics": "Mesures personnalisées",
"previewHealthScores": "Historique du score",
"restoreConfirm": "Restaurer maintenant",
"restoreInProgress": "Restauration en cours...",
"restoreSuccess": "Restauration terminée",
@@ -8085,6 +8114,23 @@
"delete": "Supprimer",
"deleteConfirmTitle": "Supprimer le rappel ?",
"deleteConfirmDescription": "Ce rappel d’examen sera définitivement supprimé.",
"postpone": {
"menuItem": "Reporter…",
"title": "Reporter l'échéance",
"description": "Ne déplace que l'échéance actuelle — le rythme régulier reste inchangé.",
"chip": "+{days} jours",
"dateLabel": "Nouvelle date",
"confirm": "Reporter"
},
"skip": {
"heading": "Passer cette échéance",
"explainer": "Enregistré honnêtement comme passé — pas comme fait. L'intervalle redémarre à partir d'aujourd'hui.",
"action": "Passer",
"confirmTitle": "Passer cette échéance ?"
},
"snoozedUntil": "Reporté au {date}",
"skippedOn": "Passé le {date}",
"skipCount": "passé {count} fois",
"disabledBadge": "Désactivé",
"nextDue": {
"none": "Non planifié",
@@ -9035,7 +9081,9 @@
"flaggedBadge": "Aide affichée",
"chartTitle": "Évolution du score",
"totalLabel": "Score",
"bandLabel": "Catégorie"
"bandLabel": "Catégorie",
"answersToggle": "Réponses",
"answersUnavailable": "Les réponses enregistrées de ce bilan ne peuvent pas être déchiffrées."
},
"crisis": {
"title": "Tu n'as pas à affronter cela seul(e)",
@@ -9739,6 +9787,8 @@
"editedToast": "Valeur mise à jour",
"editError": "Impossible de mettre à jour la valeur",
"deletedToast": "Valeur supprimée",
"restoredToast": "Valeur restaurée",
"restoreError": "Échec de la restauration",
"deleteError": "Impossible de supprimer la valeur"
},
"chart": {
@@ -10208,6 +10258,8 @@
"measurementReminderDelete": "{name} a supprimé un rappel de mesure",
"measurementReminderComplete": "{name} a terminé un rappel de mesure",
"measurementReminderSatisfy": "{name} a clôturé un rappel de mesure",
"measurementReminderSkip": "{name} a passé un rappel de mesure",
"measurementReminderSnooze": "{name} a reporté un rappel de mesure",
"labResultUpdate": "{name} a modifié un résultat de laboratoire",
"labResultDelete": "{name} a supprimé un résultat de laboratoire",
"labResultRestore": "{name} a restauré un résultat de laboratoire",
+57 -5
View File
@@ -516,6 +516,9 @@
"heroItemsTitle": "In evidenza oggi",
"heroItemsDescription": "Scegli quali tipi di elementi possono apparire nella sezione In evidenza oggi.",
"heroItemsNotificationNote": "Le impostazioni delle notifiche vengono gestite separatamente.",
"heroContentTitle": "La scheda in alto mostra",
"heroContentScore": "Punteggio di salute e riepilogo del giorno",
"heroContentReminders": "In evidenza oggi",
"heroItems": {
"coach_checkin": "Verifiche con Coach",
"dose_window": "Dosi dei farmaci",
@@ -3168,7 +3171,8 @@
"sleep": {
"title": "Sonno",
"description": "Suddivisione per fasi e durata notturna, da Apple Health.",
"headlineTitle": "Media per notte",
"mainSleep": "Sonno principale",
"totalSleep": "Sonno totale",
"compositionTitle": "Distribuzione delle fasi",
"compositionSubtitle": "Ultime {nights} notti",
"compositionAriaLabel": "Distribuzione delle fasi del sonno su {nights} notti",
@@ -4517,7 +4521,7 @@
"title": "Withings",
"body": "Bilance, misuratori di pressione e tracker Withings si sincronizzano automaticamente una volta collegati.",
"cta": "Connetti Withings",
"setupCta": "Configura nelle Impostazioni",
"setupCta": "Configura",
"setupHint": "Richiede prima la tua app sviluppatore Withings gratuita — circa 5 minuti, una tantum."
},
"more": {
@@ -4528,7 +4532,8 @@
"oura": "Oura",
"polar": "Polar",
"nightscout": "Nightscout",
"fitbit": "Fitbit"
"fitbit": "Fitbit",
"appleHealthHint": "Apple Health non ha una connessione web — completa qui la configurazione iniziale, poi apri l'app HealthLog sul tuo iPhone per completare la configurazione di Apple Health."
},
"import": "Importa la cronologia esistente"
},
@@ -5967,7 +5972,7 @@
},
"notificationStatus": {
"title": "Affidabilità dei canali",
"description": "Stato per canale; «Disattivato automaticamente» interrompe i tentativi dopo errori permanenti.",
"description": "Stato di ogni canale — «Disattivato automaticamente» significa fermato dopo errori permanenti.",
"emptyDescription": "Aggiungi un canale in Impostazioni › Integrazioni › Canali di recapito.",
"stateActive": "Attivo",
"stateAutoDisabled": "Disattivato automaticamente",
@@ -6387,6 +6392,13 @@
"importNote": "Ti serve lo storico precedente o non puoi usare l’app iOS? Il caricamento XML/ZIP di Apple Health è un’alternativa una tantum e non attiva la sincronizzazione in tempo reale.",
"importAction": "Apri importazione una tantum",
"staleNote": "Da più di una settimana non arrivano dati da Apple Health. Apri l'app HealthLog sull'iPhone e controlla i permessi di Apple Health.",
"progress": {
"title": "Avanzamento della sincronizzazione",
"receivedLabel": "Record ricevuti",
"oldestLabel": "Lettura più vecchia raggiunta",
"flowing": "Nell'ultima ora sono arrivati dati — l'app per iPhone sta ancora inviando.",
"waiting": "In attesa che l'app per iPhone invii altri dati. Il trasferimento continua quando l'app è aperta o quando iOS le concede tempo in background — una pausa durante il primo grande recupero è normale, non un errore."
},
"delivery": {
"title": "Consegna",
"lastTriggerLabel": "L'ultima sincronizzazione è arrivata",
@@ -6891,6 +6903,23 @@
"restoreTitle": "Ripristina dal backup",
"restoreDescription": "Questa operazione sostituirà TUTTI i dati attuali di {username} con l'istantanea del {when}. Misurazioni, farmaci, eventi di assunzione, voci dell'umore, canali di notifica, sottoscrizioni push ed eliminazioni Telegram in coda esistenti verranno eliminati.",
"restorePromptLabel": "Digita RESTORE per continuare",
"previewTitle": "Contenuto del backup",
"previewLoading": "Lettura del contenuto…",
"previewUnavailable": "Impossibile leggere il contenuto — il ripristino resta possibile.",
"previewEmpty": "Il file non contiene record.",
"previewOther": "Altri record",
"previewMeasurements": "Misurazioni",
"previewMedications": "Farmaci e assunzioni",
"previewMood": "Voci dell'umore",
"previewLabs": "Risultati di laboratorio",
"previewDocuments": "Documenti",
"previewWorkouts": "Allenamenti",
"previewCycles": "Dati del ciclo",
"previewVisits": "Visite mediche",
"previewVaccinations": "Vaccinazioni",
"previewReminders": "Promemoria di prevenzione",
"previewCustomMetrics": "Metriche personalizzate",
"previewHealthScores": "Storico del punteggio",
"restoreConfirm": "Ripristina ora",
"restoreInProgress": "Ripristino in corso...",
"restoreSuccess": "Ripristino completato",
@@ -8085,6 +8114,23 @@
"delete": "Elimina",
"deleteConfirmTitle": "Eliminare il promemoria?",
"deleteConfirmDescription": "Questo promemoria di controllo verrà eliminato definitivamente.",
"postpone": {
"menuItem": "Rimanda…",
"title": "Rimanda la scadenza",
"description": "Sposta solo la scadenza attuale — il ritmo regolare resta invariato.",
"chip": "+{days} giorni",
"dateLabel": "Nuova data",
"confirm": "Rimanda"
},
"skip": {
"heading": "Salta questa scadenza",
"explainer": "Registrato onestamente come saltato — non come fatto. L'intervallo riparte da oggi.",
"action": "Salta",
"confirmTitle": "Saltare questa scadenza?"
},
"snoozedUntil": "Rimandato al {date}",
"skippedOn": "Saltato il {date}",
"skipCount": "saltato {count} volte",
"disabledBadge": "Disattivato",
"nextDue": {
"none": "Non pianificato",
@@ -9035,7 +9081,9 @@
"flaggedBadge": "Supporto mostrato",
"chartTitle": "Andamento del punteggio",
"totalLabel": "Punteggio",
"bandLabel": "Categoria"
"bandLabel": "Categoria",
"answersToggle": "Risposte",
"answersUnavailable": "Le risposte salvate di questo check-in non possono essere decifrate."
},
"crisis": {
"title": "Non devi affrontare tutto questo da solo/a",
@@ -9739,6 +9787,8 @@
"editedToast": "Valore aggiornato",
"editError": "Impossibile aggiornare il valore",
"deletedToast": "Valore eliminato",
"restoredToast": "Valore ripristinato",
"restoreError": "Ripristino non riuscito",
"deleteError": "Impossibile eliminare il valore"
},
"chart": {
@@ -10208,6 +10258,8 @@
"measurementReminderDelete": "{name} ha eliminato un promemoria di misurazione",
"measurementReminderComplete": "{name} ha completato un promemoria di misurazione",
"measurementReminderSatisfy": "{name} ha chiuso un promemoria di misurazione",
"measurementReminderSkip": "{name} ha saltato un promemoria di misurazione",
"measurementReminderSnooze": "{name} ha rimandato un promemoria di misurazione",
"labResultUpdate": "{name} ha modificato un risultato di laboratorio",
"labResultDelete": "{name} ha eliminato un risultato di laboratorio",
"labResultRestore": "{name} ha ripristinato un risultato di laboratorio",
+57 -5
View File
@@ -516,6 +516,9 @@
"heroItemsTitle": "Dzisiejsze wyróżnienia",
"heroItemsDescription": "Wybierz typy elementów, które mogą pojawiać się w dzisiejszych wyróżnieniach.",
"heroItemsNotificationNote": "Ustawieniami powiadomień zarządzasz osobno.",
"heroContentTitle": "Górna karta pokazuje",
"heroContentScore": "Wynik zdrowia i podsumowanie dnia",
"heroContentReminders": "Dzisiejsze wyróżnienia",
"heroItems": {
"coach_checkin": "Podsumowania z Coachem",
"dose_window": "Dawki leków",
@@ -3168,7 +3171,8 @@
"sleep": {
"title": "Sen",
"description": "Podział na fazy i nocna długość snu, dane z Apple Health.",
"headlineTitle": "Średnia na noc",
"mainSleep": "Sen główny",
"totalSleep": "Sen łączny",
"compositionTitle": "Rozkład faz",
"compositionSubtitle": "Ostatnie {nights} nocy",
"compositionAriaLabel": "Rozkład faz snu w {nights} nocach",
@@ -4517,7 +4521,7 @@
"title": "Withings",
"body": "Wagi, ciśnieniomierze i trackery Withings synchronizują się automatycznie po połączeniu.",
"cta": "Połącz Withings",
"setupCta": "Skonfiguruj w Ustawieniach",
"setupCta": "Skonfiguruj",
"setupHint": "Najpierw wymaga własnej, darmowej aplikacji deweloperskiej Withings — około 5 minut, jednorazowo."
},
"more": {
@@ -4528,7 +4532,8 @@
"oura": "Oura",
"polar": "Polar",
"nightscout": "Nightscout",
"fitbit": "Fitbit"
"fitbit": "Fitbit",
"appleHealthHint": "Apple Health nie ma połączenia przez przeglądarkę — dokończ tu wstępną konfigurację, a następnie otwórz aplikację HealthLog na iPhonie, aby dokończyć konfigurację Apple Health."
},
"import": "Importuj istniejącą historię"
},
@@ -5967,7 +5972,7 @@
},
"notificationStatus": {
"title": "Niezawodność kanałów",
"description": "Stan każdego kanału; „Automatycznie wyłączone” przerywa ponawianie po trwałych błędach.",
"description": "Stan każdego kanału — „Automatycznie wyłączone” oznacza zatrzymanie po trwałych błędach.",
"emptyDescription": "Dodaj kanał w Ustawienia › Integracje › Kanały dostarczania.",
"stateActive": "Aktywne",
"stateAutoDisabled": "Automatycznie wyłączone",
@@ -6387,6 +6392,13 @@
"importNote": "Potrzebujesz starszej historii lub nie możesz użyć aplikacji iOS? Przesłanie pliku XML/ZIP z Apple Health jest jednorazową alternatywą i nie włącza synchronizacji na żywo.",
"importAction": "Otwórz jednorazowy import",
"staleNote": "Od ponad tygodnia nie napłynęły żadne dane z Apple Health. Otwórz aplikację HealthLog na iPhonie i sprawdź uprawnienia Apple Health.",
"progress": {
"title": "Postęp synchronizacji",
"receivedLabel": "Odebrane rekordy",
"oldestLabel": "Najstarszy osiągnięty pomiar",
"flowing": "W ciągu ostatniej godziny dotarły dane — aplikacja na iPhonie nadal je przesyła.",
"waiting": "Oczekiwanie na kolejne dane z aplikacji na iPhonie. Przesyłanie trwa dalej, gdy aplikacja jest otwarta lub gdy iOS przydzieli jej czas w tle — cisza podczas pierwszego dużego uzupełniania historii jest normalna, to nie błąd."
},
"delivery": {
"title": "Dostarczanie",
"lastTriggerLabel": "Ostatnia synchronizacja dotarła",
@@ -6891,6 +6903,23 @@
"restoreTitle": "Przywróć z kopii zapasowej",
"restoreDescription": "Spowoduje to zastąpienie WSZYSTKICH bieżących danych {username} migawką z {when}. Istniejące pomiary, leki, zdarzenia przyjęcia, wpisy nastroju, kanały powiadomień, subskrypcje push i zakolejkowane usunięcia Telegram zostaną usunięte.",
"restorePromptLabel": "Wpisz RESTORE, aby kontynuować",
"previewTitle": "Zawartość kopii",
"previewLoading": "Odczytywanie zawartości…",
"previewUnavailable": "Nie udało się odczytać zawartości — przywracanie jest nadal możliwe.",
"previewEmpty": "Plik nie zawiera rekordów.",
"previewOther": "Inne rekordy",
"previewMeasurements": "Pomiary",
"previewMedications": "Leki i dawki",
"previewMood": "Wpisy nastroju",
"previewLabs": "Wyniki badań",
"previewDocuments": "Dokumenty",
"previewWorkouts": "Treningi",
"previewCycles": "Dane cyklu",
"previewVisits": "Wizyty lekarskie",
"previewVaccinations": "Szczepienia",
"previewReminders": "Przypomnienia profilaktyczne",
"previewCustomMetrics": "Własne metryki",
"previewHealthScores": "Historia wyniku",
"restoreConfirm": "Przywróć teraz",
"restoreInProgress": "Przywracanie...",
"restoreSuccess": "Przywracanie zakończone",
@@ -8085,6 +8114,23 @@
"delete": "Usuń",
"deleteConfirmTitle": "Usunąć przypomnienie?",
"deleteConfirmDescription": "To przypomnienie o badaniu zostanie trwale usunięte.",
"postpone": {
"menuItem": "Przełóż…",
"title": "Przełóż termin",
"description": "Przesuwa tylko bieżący termin — regularny rytm pozostaje bez zmian.",
"chip": "+{days} dni",
"dateLabel": "Nowa data",
"confirm": "Przełóż"
},
"skip": {
"heading": "Pomiń ten termin",
"explainer": "Zapisane uczciwie jako pominięte — nie jako wykonane. Odstęp zaczyna się od nowa od dzisiaj.",
"action": "Pomiń",
"confirmTitle": "Pominąć ten termin?"
},
"snoozedUntil": "Przełożono do {date}",
"skippedOn": "Pominięto {date}",
"skipCount": "pominięto {count} razy",
"disabledBadge": "Wył.",
"nextDue": {
"none": "Niezaplanowane",
@@ -9035,7 +9081,9 @@
"flaggedBadge": "Pokazano wsparcie",
"chartTitle": "Przebieg wyniku",
"totalLabel": "Wynik",
"bandLabel": "Kategoria"
"bandLabel": "Kategoria",
"answersToggle": "Odpowiedzi",
"answersUnavailable": "Nie można odszyfrować zapisanych odpowiedzi tego sprawdzenia."
},
"crisis": {
"title": "Nie musisz mierzyć się z tym sam(a)",
@@ -9739,6 +9787,8 @@
"editedToast": "Zaktualizowano wartość",
"editError": "Nie udało się zaktualizować wartości",
"deletedToast": "Usunięto wartość",
"restoredToast": "Przywrócono wartość",
"restoreError": "Nie udało się przywrócić",
"deleteError": "Nie udało się usunąć wartości"
},
"chart": {
@@ -10208,6 +10258,8 @@
"measurementReminderDelete": "{name} usunął przypomnienie o pomiarze",
"measurementReminderComplete": "{name} zrealizował przypomnienie o pomiarze",
"measurementReminderSatisfy": "{name} zamknął przypomnienie o pomiarze",
"measurementReminderSkip": "{name} pominął(-ęła) przypomnienie o pomiarze",
"measurementReminderSnooze": "{name} przełożył(-yła) przypomnienie o pomiarze",
"labResultUpdate": "{name} zmienił wynik badania",
"labResultDelete": "{name} usunął wynik badania",
"labResultRestore": "{name} przywrócił wynik badania",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.37.19",
"version": "1.37.20",
"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,54 @@
-- Refs #223 + iOS #68 — Vorsorge snooze/skip + the completion ledger.
-- All additive; every existing reminder keeps its exact behaviour.
-- Snooze cursor: non-NULL and in the future = "pushed back until this
-- instant". The snooze write sets next_due_at to the SAME instant, so every
-- due-state consumer moves with it; expiry is the clock passing the value.
ALTER TABLE "measurement_reminders" ADD COLUMN "snoozed_until" TIMESTAMP(3);
-- Last honest skip. A skip re-anchors next_due_at at the skip instant but
-- never moves last_satisfied_at — done and skipped stay distinguishable.
ALTER TABLE "measurement_reminders" ADD COLUMN "last_skipped_at" TIMESTAMP(3);
-- Lifetime skip counter ("skipped N times").
ALTER TABLE "measurement_reminders" ADD COLUMN "skip_count" INTEGER NOT NULL DEFAULT 0;
-- The completion ledger (iOS #68). The engine is single-cursor, so before
-- this table each satisfy overwrote the last and history was unrecoverable.
-- Append-only: one row per satisfy / skip, written by the one satisfy
-- primitive and the skip route. History starts with this release — the
-- cursor holds nothing to backfill from.
CREATE TYPE "measurement_reminder_event_kind" AS ENUM ('SATISFIED', 'SKIPPED');
CREATE TABLE "measurement_reminder_events" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"reminder_id" TEXT NOT NULL,
"kind" "measurement_reminder_event_kind" NOT NULL,
"occurred_at" TIMESTAMP(3) NOT NULL,
"on_time" BOOLEAN NOT NULL,
"source" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "measurement_reminder_events_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "measurement_reminder_events_reminder_id_occurred_at_idx"
ON "measurement_reminder_events"("reminder_id", "occurred_at");
CREATE INDEX "measurement_reminder_events_user_id_idx"
ON "measurement_reminder_events"("user_id");
ALTER TABLE "measurement_reminder_events"
ADD CONSTRAINT "measurement_reminder_events_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "measurement_reminder_events"
ADD CONSTRAINT "measurement_reminder_events_reminder_id_fkey"
FOREIGN KEY ("reminder_id") REFERENCES "measurement_reminders"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
-- Custom-metric entries gain the soft-delete tombstone every peer entry
-- surface already has (undo parity — the one hard delete left).
ALTER TABLE "custom_metric_entries" ADD COLUMN "deleted_at" TIMESTAMP(3);
+115 -8
View File
@@ -102,6 +102,10 @@ model User {
// Codex OAuth (ChatGPT subscription)
codexAccessTokenEncrypted String? @map("codex_access_token_encrypted")
codexRefreshTokenEncrypted String? @map("codex_refresh_token_encrypted")
/// @internal: dead mirror of the expiry inside the encrypted Codex creds —
/// the refresh path reads the decrypted blob's own `expiresAt`
/// (`src/lib/ai/provider.ts`), never this column. Kept (and kept written)
/// for wire/backup compatibility rather than dropped.
codexTokenExpiresAt DateTime? @map("codex_token_expires_at")
codexConnectedAt DateTime? @map("codex_connected_at")
codexConnectionStatus String @default("disconnected") @map("codex_connection_status")
@@ -879,6 +883,9 @@ model User {
// Server computes the canonical `nextDueAt`; the cron fires through the
// existing notification dispatcher. Soft-deleted.
measurementReminders MeasurementReminder[]
// v1.37.20 — completion ledger behind the reminders (iOS #68): one
// append-only row per satisfy / skip. Cascade-deleted with the account.
measurementReminderEvents MeasurementReminderEvent[]
// v1.17.1 — minimal structured lab store (LDL, HbA1c, ferritin, TSH, …)
// that pairs with the Vorsorge "annual blood panel" reminder so a result
// has somewhere to land. Soft-deleted.
@@ -1045,6 +1052,9 @@ model EnvironmentContext {
/// that won precedence).
lat Float
lon Float
/// @internal: human-readable label of the resolved location, stored as
/// operator/debug provenance beside the coordinates; readers select the
/// numeric and weather columns only, never this label.
locationLabel String @map("location_label")
source EnvironmentLocationSource
@@ -2416,6 +2426,9 @@ model PersonalRecord {
/// FK to the Measurement row that achieved the record. SET NULL
/// on delete so the historical fact survives even if the
/// underlying measurement is later removed.
/// @internal: audit-trail provenance written by the PR detection worker;
/// no read path selects it — the record row itself carries the surfaced
/// value/unit/achievedAt.
sourceMeasurementId String? @map("source_measurement_id")
source MeasurementSource @default(MANUAL)
externalId String? @map("external_id")
@@ -3581,10 +3594,16 @@ model McpOAuthConnection {
model InviteToken {
id String @id @default(cuid())
tokenHash String @unique @map("token_hash")
/// Consumed through the `creator` relation: the admin invites list
/// (`src/app/api/admin/invites/route.ts`) selects the creating admin's
/// id + username via it.
createdBy String @map("created_by")
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime @map("expires_at")
usedAt DateTime? @map("used_at")
/// LAST consumer only (overwritten on multi-use invites; the full history
/// lives in `InviteRedemption`). Consumed through the `consumer` relation
/// by the admin invites list (`src/app/api/admin/invites/route.ts`).
usedBy String? @map("used_by")
maxUses Int @default(1) @map("max_uses")
uses Int @default(0)
@@ -3641,8 +3660,11 @@ model RefreshToken {
/// When this token was used (one-time-use). Set during rotation; a second
/// use of the same token is a hard fail and triggers reuse-detection.
usedAt DateTime? @map("used_at")
/// Pointer to the row that replaced this one during rotation. Helps audit
/// trail and reuse-detection ("this token was already exchanged into X").
/// Pointer to the row that replaced this one during rotation ("this token
/// was already exchanged into X").
/// @internal: audit-trail bookkeeping written by the rotation path
/// (`src/lib/auth/refresh-token.ts`); reuse-detection keys on
/// `usedAt`/`tokenHash`, so no code path reads this pointer.
replacedById String? @map("replaced_by_id")
userAgent String? @map("user_agent")
ipAddress String? @map("ip_address")
@@ -4920,12 +4942,18 @@ model ImportJob {
failureReason String? @map("failure_reason")
/// Bytes uploaded — surfaced to the operator audit trail.
uploadBytes Int @map("upload_bytes")
/// SHA-256 hex digest of the uploaded `export.zip`. Lets the
/// kick-off endpoint short-circuit a re-upload of the exact same
/// file back to the existing job id without re-queueing.
/// SHA-256 hex digest of the uploaded `export.zip`. Consumer: both kick-off
/// endpoints (`src/app/api/import/apple-health-export/route.ts` and the
/// admin variant) dedupe on (userId, uploadSha256, parserRevision) and
/// short-circuit a re-upload of the exact same file back to the existing
/// job id without re-queueing. A prior `failed` job is excluded from the
/// match (issue #486), so the same bytes can always be retried.
uploadSha256 String? @map("upload_sha256")
/// Parser semantics revision. Existing archives migrated at revision 1 can
/// be deliberately reprocessed when the current parser revision advances.
/// be deliberately reprocessed when the current parser revision advances:
/// the kick-off dedupe lookup (see `uploadSha256`) pins the CURRENT
/// `APPLE_HEALTH_IMPORT_PARSER_REVISION`, so a job parsed under an older
/// revision never short-circuits a fresh upload of the same bytes.
parserRevision Int @default(1) @map("parser_revision")
/// Apple's `ExportDate` attribute string-formatted, parsed during
/// the unpack phase. NULL until the parser sees it.
@@ -5187,6 +5215,20 @@ model MeasurementReminder {
/// excluded everywhere), whereas a booster is the engine's native case —
/// recurring, satisfiable, cadence-bearing.
vaccinationAntigen String? @map("vaccination_antigen")
/// v1.37.20 — snooze cursor. Non-NULL and in the future = "pushed back until
/// this instant". The snooze write sets `nextDueAt` to the SAME instant, so
/// every due-state consumer (digest, dashboard, cron, AI block) moves with
/// it without knowing snooze exists. Self-expiring: once the instant passes,
/// the clock comparison stops being true and no cleanup is needed. Any
/// recompute of `nextDueAt` (satisfy, skip, cadence PATCH) nulls it.
snoozedUntil DateTime? @map("snoozed_until")
/// v1.37.20 — last honest skip. A skip re-anchors `nextDueAt` at the skip
/// instant but NEVER moves `lastSatisfiedAt` — done and skipped stay
/// distinguishable forever. "Skipped cycle" derives as
/// `lastSkippedAt > lastSatisfiedAt` (or non-NULL with no satisfy yet).
lastSkippedAt DateTime? @map("last_skipped_at")
/// v1.37.20 — lifetime skip counter ("skipped N times").
skipCount Int @default(0) @map("skip_count")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
@@ -5201,6 +5243,10 @@ model MeasurementReminder {
/// this reminder. SetNull on the far side — deleting the reminder leaves
/// the vaccination record intact.
vaccinations VaccinationRecord[]
/// v1.37.20 — the completion ledger (iOS #68): every satisfy and every
/// skip appends one row. Cascade: the ledger has no meaning without its
/// reminder.
events MeasurementReminderEvent[]
@@index([userId, deletedAt])
@@index([userId, nextDueAt])
@@ -5234,6 +5280,55 @@ enum ReminderOrigin {
@@map("reminder_origin")
}
/// v1.37.20 — what a completion-ledger row records: an honest fulfilment
/// (`SATISFIED` — a matching reading landed or the user marked it done) or an
/// honest skip (`SKIPPED` — the user deliberately let this cycle go, which is
/// NOT a completion and never touches `lastSatisfiedAt`).
enum MeasurementReminderEventKind {
SATISFIED
SKIPPED
@@map("measurement_reminder_event_kind")
}
/// v1.37.20 — the Vorsorge completion ledger (iOS #68). The reminder engine is
/// single-cursor (`nextDueAt` + `lastSatisfiedAt`), so before this table each
/// satisfy overwrote the previous one and history was unrecoverable. Every
/// path through the ONE satisfy primitive (`satisfyReminder`) and the skip
/// route appends one row here; nothing ever updates or deletes a row short of
/// deleting the reminder itself. History starts at the release that ships
/// this — the single cursor holds nothing to backfill from, which the iOS #68
/// thread records as agreed.
model MeasurementReminderEvent {
id String @id @default(cuid())
userId String @map("user_id")
reminderId String @map("reminder_id")
kind MeasurementReminderEventKind
/// The event instant: the satisfy instant (which may be the backdated
/// reading's own timestamp) or the skip instant (always the server clock).
occurredAt DateTime @map("occurred_at")
/// Derived AT WRITE TIME against the reminder's `nextDueAt` as it stood
/// before the event re-anchored it: `true` when the event landed at or
/// before the due instant (or the cadence had no computable due). Clients
/// (web + iOS) consume this resolved value and never re-derive it — the
/// pre-event `nextDueAt` is gone after the write, so write time is the only
/// honest moment to answer the question.
onTime Boolean @map("on_time")
/// Which path produced the event — a closed set of engine call sites
/// (`manual`, `auto_measurement`, `auto_lab`, `telegram`, `vaccination`,
/// `encounter`, `skip`), asserted in code, not free text.
source String
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
reminder MeasurementReminder @relation(fields: [reminderId], references: [id], onDelete: Cascade)
/// The history endpoint reads newest-first per reminder.
@@index([reminderId, occurredAt])
@@index([userId])
@@map("measurement_reminder_events")
}
// ─── Lab results ─────────────────────────────────────────────
/// v1.17.1 — minimal structured lab store pairing with the Vorsorge "annual
@@ -5432,6 +5527,10 @@ model CustomMetricEntry {
measuredAt DateTime @map("measured_at")
note String?
createdAt DateTime @default(now()) @map("created_at")
/// v1.37.20 — soft-delete tombstone, peer parity with Measurement /
/// MoodEntry (every other entry surface deletes recoverably with an undo
/// toast; custom-metric entries were the one hard delete). NULL = live.
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
metric CustomMetric @relation(fields: [customMetricId], references: [id], onDelete: Cascade)
@@ -5695,6 +5794,8 @@ model CoachConversation {
// conversation keeps memory of what came before without re-sending every
// turn to the provider. AES-256-GCM (same codec as CoachMessage.encrypted).
summaryEncrypted Bytes? @map("summary_encrypted")
/// @internal: diagnostic timestamp of the last summary rewrite — the elision
/// logic keys on `summaryTurnCount`, not on this instant; nothing reads it.
summaryUpdatedAt DateTime? @map("summary_updated_at")
summaryTurnCount Int @default(0) @map("summary_turn_count")
@@ -7713,7 +7814,12 @@ model DocumentContentIndex {
/// Provider type that produced the text (diagnostic only), or null on the
/// text-ocr path.
providerType String? @map("provider_type")
/// Tokeniser algorithm version — bump to force a re-index on a scheme change.
/// Tokeniser algorithm version — bump `CONTENT_TOKENIZER_VERSION`
/// (`src/lib/documents/content-index.ts`) to force a re-index on a scheme
/// change. Consumer: the content-index backfill job re-tokenises any row
/// whose stored version trails the current one from the decrypted
/// `textEncrypted` (`retokeniseStaleRows` in
/// `src/lib/jobs/document-content-index-backfill.ts`).
tokenizerVersion String @map("tokenizer_version")
createdAt DateTime @default(now()) @map("created_at")
@@ -7750,7 +7856,8 @@ model DocumentThumbnail {
height Int
/// Plaintext byte length of the JPEG (pre-encryption), for `Content-Length`.
byteSize Int @map("byte_size")
/// Source MIME the preview was rendered from (diagnostic only).
/// @internal: source MIME the preview was rendered from — operator-side
/// diagnostic provenance only; no query selects it and no client surfaces it.
sourceMime String @map("source_mime")
createdAt DateTime @default(now()) @map("created_at")
+4 -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.37.19";
/* @sw-version-fallback */ "v1.37.20";
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/*`
@@ -79,6 +79,9 @@ const MAX_DATA_ENTRIES = 60;
// and the version probe are eligible.
const API_READ_ALLOWLIST = [
"/api/dashboard/snapshot",
// v1.37.20 — daily digest: the Today rail's offline read. Keep in
// lockstep with src/lib/pwa/sw-cache-policy.ts.
"/api/daily/digest",
"/api/dashboard/widgets",
"/api/measurements",
"/api/medications",
@@ -462,6 +462,20 @@ describe("every backed-up model travels both ways, or is named as debt", () => {
).toEqual([]);
});
it("proves the reminder engine models are on the covered side", () => {
// v1.37.20 (#223 / iOS #68) — the reminder came off the debt register and
// the completion ledger landed carried from the release that introduced
// it. Pinned by name so a future edit cannot quietly move either back to
// COVERAGE_PENDING: the ledger is the only copy of every satisfy and skip
// before the latest one, so "pending" would be a silent history loss, not
// a deferral.
for (const model of ["MeasurementReminder", "MeasurementReminderEvent"]) {
expect(BACKED_UP_MODELS).toContain(model);
expect(TWO_ENDED_MODELS).toContain(model);
expect(COVERAGE_PENDING).not.toHaveProperty(model);
}
});
it("proves the structured-profile models are on the covered side", () => {
// These account-owned rows are exported and restored explicitly rather
// than being treated as reconstructible data.
@@ -280,4 +280,36 @@ describe("data-wipe completeness", () => {
OR: [{ recordUserId: "u1" }, { recipientUserId: "u1" }],
});
});
it("freezes the set of user-linked tables that carry no foreign key", () => {
// v1.37.20 (A6-19) — NO_FK_BY_DESIGN, as an assertion instead of a pair
// of FK migrations. The two Telegram plumbing tables link to an account
// by a bare `userId` string on purpose: both hold a bounded, short-lived
// window of chat state (scheduled message deletions, prompt contexts)
// that a cron already reaps, and a cascade would buy nothing the wipe
// plan does not already do — rule 1 catches them because the column NAME
// matches the convention. That is the exact fragility this test freezes:
// a THIRD such table would also pass rule 1 silently, without anyone
// deciding it should. So the set is closed here; growing it is a
// decision made in this file, with a reason, or by adding the FK.
const NO_FK_BY_DESIGN = new Set([
"TelegramScheduledDeletion",
"TelegramPromptContext",
]);
const bareUserIdModels = [...models.entries()]
.filter(
([name, shape]) =>
name !== "User" &&
shape.scalars.includes("userId") &&
!shape.relations.some((rel) => rel.target === "User"),
)
.map(([name]) => name)
.sort();
expect(bareUserIdModels).toEqual([...NO_FK_BY_DESIGN].sort());
// And both stay in the wipe plan — the assertion above is about schema
// shape, this one about the plan actually reaching them.
for (const model of NO_FK_BY_DESIGN) {
expect(WIPE_MODELS).toContain(model);
}
});
});
@@ -97,6 +97,9 @@ function reminderRow(
location: null,
nextDueAt: at,
lastSatisfiedAt: null,
snoozedUntil: null,
lastSkippedAt: null,
skipCount: 0,
enabled: true,
vaccinationAntigen: null,
createdAt: at,
@@ -152,6 +155,27 @@ const BY_ID_SITES: ReadonlyArray<{
sites: 1,
surface: "the manual satisfy action",
},
{
// v1.37.20 (#223) — skipping an appointment would move a date the visit
// record still believes it owns; snoozing one likewise. Both refuse in
// the lookup, before any write. This entry went in RED (the routes did
// not exist yet) and turned green when they landed with the exclusion.
file: "src/app/api/measurement-reminders/[id]/skip/route.ts",
sites: 1,
surface: "the honest skip action",
},
{
file: "src/app/api/measurement-reminders/[id]/snooze/route.ts",
sites: 1,
surface: "the snooze action",
},
{
// v1.37.20 (iOS #68) — the ledger read answers 404 for an appointment
// row like every by-id sibling.
file: "src/app/api/measurement-reminders/[id]/history/route.ts",
sites: 1,
surface: "the completion-ledger history read",
},
{
file: "src/lib/telegram-webhook-handlers.ts",
sites: 2,
@@ -161,9 +185,9 @@ const BY_ID_SITES: ReadonlyArray<{
describe("an appointment reminder is not addressable by id", () => {
it("names a by-id set that is not empty (no vacuous pass)", () => {
expect(BY_ID_SITES.length).toBe(4);
expect(BY_ID_SITES.length).toBe(7);
expect(BY_ID_SITES.reduce((total, entry) => total + entry.sites, 0)).toBe(
7,
10,
);
});
+36 -2
View File
@@ -875,6 +875,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "mind",
why: "The record's screener history. The module gate resolves against the record, so a delegate sees the surface only where the owner switched it on; the rate limit on the POST arm is untouched and that arm still refuses under a switch.",
},
"app/api/mental-health/assessments/[id]/route.ts": {
domain: "mind",
why: "One administration of the record, fetch-then-guard against the resolved user, WITH its decrypted per-item answers. Admitted at the same level as the list beside it deliberately: the mind read grant already serves the mood diary's decrypted prose and the list already publishes the item-9 flag, so the per-item values sit inside what the domain shows, not beyond it. The route comment carries the argument; the module gate resolves against the record like the sibling.",
},
"app/api/anamnesis/facts/route.ts": {
domain: "profile",
why: "The record's health-profile facts, read through `readHealthProfileFacts(user.id)`. The module holds write helpers, and the GET calls none of them.",
@@ -1140,6 +1144,18 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "measurements",
why: "The same primitive from the other caller. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
},
"app/api/measurement-reminders/[id]/skip/route.ts": {
domain: "measurements",
why: "Honestly skipping the current due cycle (v1.37.20, #223) — the counterpart of complete/satisfy, restarting the interval without claiming fulfilment. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
},
"app/api/measurement-reminders/[id]/snooze/route.ts": {
domain: "measurements",
why: "Pushing one due date back to a calendar day (v1.37.20, #223), the cadence untouched. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
},
"app/api/measurement-reminders/[id]/history/route.ts": {
domain: "measurements",
why: "The completion ledger behind one reminder (v1.37.20, iOS #68), read-only and newest-first. The lookup binds the resolved user id and refuses an appointment row like every by-id sibling.",
},
"app/api/labs/restore/route.ts": {
domain: "labs",
why: "Restoring tombstoned results, scoped so a foreign or live id is a no-op. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
@@ -1477,6 +1493,16 @@ const DELEGABLE_MANAGE_ROUTES: Record<string, ManageEntry> = {
conditions: [],
why: "The same primitive from the other caller.",
},
"app/api/measurement-reminders/[id]/skip/route.ts": {
domain: "measurements",
conditions: [],
why: "Honestly letting one due cycle go (v1.37.20, #223). Additive management of a schedule the level admits — the interval restarts, lastSatisfiedAt is never touched.",
},
"app/api/measurement-reminders/[id]/snooze/route.ts": {
domain: "measurements",
conditions: [],
why: "Pushing one due date back (v1.37.20, #223). Additive, self-expiring, the anchor untouched.",
},
"app/api/labs/[id]/route.ts": {
domain: "labs",
conditions: ["C4"],
@@ -1911,8 +1937,16 @@ const ACTOR_ROUTES: Record<string, string> = {
* v1.38.0 -- the immunization log adds six: two reads and one restore on the
* record list, one create on the write literal, and the edit/delete pair plus
* the restore on the manage literal. 215 -> 221.
*
* v1.37.20 (#223 / iOS #68) -- Vorsorge skip/snooze adds five: skip, snooze
* and the completion-ledger history read on the record list, plus skip and
* snooze on the manage literal. 224 -> 229.
*
* The screener detail read adds one: the per-administration read that
* decrypts the stored item answers, on the record list at the same level as
* the screener history beside it. 229 -> 230.
*/
const FROZEN_ENTRY_COUNT = 224;
const FROZEN_ENTRY_COUNT = 230;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -2673,7 +2707,7 @@ describe("(g) the MANAGE route set is frozen", () => {
it("keeps the admitted mutation inventory complete and discoverable", () => {
expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0);
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(75);
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(77);
const expected = ADMITTED_MUTATING_HANDLERS.map(
({ handlerModule, action, level }) =>
@@ -140,7 +140,9 @@ const PINNED_AFFORDANCES: Record<
"toast.success": 1,
},
"src/components/custom-metrics/custom-metric-history-list.tsx": {
"toast.success": 2,
// v1.37.20 (A3-11) — the third success is the restore confirmation
// behind the delete toast's Undo action.
"toast.success": 3,
},
"src/components/cycle/cycle-phase-crosstab.tsx": { "text-success": 1 },
// v1.36.x — two call sites, one decision: the intake toast routes through
@@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => {
});
it("keeps every admitted mutation uniquely addressable and fully controlled", () => {
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(75);
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(77);
expect(
new Set(
ADMITTED_MUTATING_HANDLERS.map(
@@ -292,6 +292,10 @@ function sourceClient() {
encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
// The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the
// same reason as the visit tables above.
measurementReminder: { findMany: vi.fn().mockResolvedValue([]) },
measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) },
customMetric: {
findMany: vi.fn().mockResolvedValue([
{
+34 -10
View File
@@ -50,6 +50,7 @@ import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup
import { restoreHealthScoreData } from "@/lib/export/health-score-backup";
import { restoreVisitsData } from "@/lib/export/visits-backup";
import { restoreVaccinationsData } from "@/lib/export/vaccinations-backup";
import { restoreRemindersData } from "@/lib/export/reminders-backup";
import { invalidateUserData } from "@/lib/cache/invalidate";
export const dynamic = "force-dynamic";
@@ -95,6 +96,8 @@ interface RestoreResponse {
encounterLinks: number;
vaccinations: number;
vaccinationLinks: number;
measurementReminders: number;
measurementReminderEvents: number;
};
}
@@ -1329,12 +1332,28 @@ const handler = apiHandler(
});
}
// Visits, the address book and the three link tables. Last on
// purpose: a link is written only when both of its ends exist, and
// the documents above are the final far side to land. Both ends of
// this section live in `src/lib/export/visits-backup.ts`, so a grep
// of THIS file alone will not find them — the same delegation as the
// cycle, profile, intraday and score restores above.
// The Vorsorge reminders and their completion ledger (v1.37.20,
// #223 / iOS #68), BEFORE the visits and the vaccinations: both of
// those remap a `reminderId` against the reminders in the database,
// so the reminders have to be in the database first or every
// appointment and booster reference would read as unresolvable and
// drop. Both ends of this section live in
// `src/lib/export/reminders-backup.ts`.
const remindersCleared = await restoreRemindersData(
tx,
ownerId,
payload,
skips,
);
// Visits, the address book and the three link tables. After the
// documents and the reminders on purpose: a link is written only
// when both of its ends exist, and the encounter's reminder
// reference remaps against the rows the branch above has just
// written. Both ends of this section live in
// `src/lib/export/visits-backup.ts`, so a grep of THIS file alone
// will not find them — the same delegation as the cycle, profile,
// intraday and score restores above.
const visitsCleared = await restoreVisitsData(
tx,
ownerId,
@@ -1343,10 +1362,10 @@ const handler = apiHandler(
);
// The immunization log, after the visits: a dose remaps its
// practitioner and its encounter against rows the branch above has
// just written, and its document link needs the documents that
// landed before that. Both ends of this section live in
// `src/lib/export/vaccinations-backup.ts`.
// practitioner, its encounter and its booster reminder against rows
// the branches above have just written, and its document link needs
// the documents that landed before that. Both ends of this section
// live in `src/lib/export/vaccinations-backup.ts`.
const vaccinationsCleared = await restoreVaccinationsData(
tx,
ownerId,
@@ -1385,6 +1404,9 @@ const handler = apiHandler(
encounterLinks: visitsCleared.encounterLinks,
vaccinations: vaccinationsCleared.vaccinations,
vaccinationLinks: vaccinationsCleared.vaccinationLinks,
measurementReminders: remindersCleared.measurementReminders,
measurementReminderEvents:
remindersCleared.measurementReminderEvents,
};
return { cleared, skipped: summarizeRestoreSkips(skips) };
},
@@ -1537,6 +1559,8 @@ const handler = apiHandler(
encounterLinks: summary.encounterLinks,
vaccinations: summary.vaccinations,
vaccinationLinks: summary.vaccinationLinks,
measurementReminders: summary.measurementReminders,
measurementReminderEvents: summary.measurementReminderEvents,
},
},
});
@@ -0,0 +1,95 @@
/**
* v1.37.20 — restore preview: the summary read decrypts and validates the
* stored file through the SAME helpers the restore uses, and only ever
* reads. Watched red: written against the route before its annotate landed;
* the not-found and decrypt-failure arms were asserted first.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAdmin: vi.fn(async () => ({ user: { id: "admin-1" } })),
HttpError: class HttpError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
}
},
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
const findUniqueMock = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
dataBackup: { findUnique: (...a: unknown[]) => findUniqueMock(...a) },
},
}));
const decryptMock = vi.fn();
vi.mock("@/lib/crypto", () => ({
decrypt: (...a: unknown[]) => decryptMock(...a),
}));
const parseMock = vi.fn();
const summarizeMock = vi.fn();
vi.mock("@/lib/validations/backup", () => ({
parseBackupPayload: (...a: unknown[]) => parseMock(...a),
summarizeBackup: (...a: unknown[]) => summarizeMock(...a),
isCompatibleSchemaVersion: vi.fn(() => true),
}));
import { GET } from "../route";
const request = new NextRequest(
"http://localhost/api/admin/backups/b1/summary",
);
const params = { params: Promise.resolve({ id: "b1" }) };
beforeEach(() => {
vi.clearAllMocks();
findUniqueMock.mockResolvedValue({
id: "b1",
userId: "u1",
data: "ciphertext",
createdAt: new Date("2026-08-01T00:00:00Z"),
user: { id: "u1", username: "self-hoster" },
});
decryptMock.mockReturnValue("{}");
parseMock.mockReturnValue({ schemaVersion: 3 });
summarizeMock.mockReturnValue({ measurements: 12, moodEntries: 3 });
});
describe("GET /api/admin/backups/[id]/summary", () => {
it("answers the file's counts without writing anything", async () => {
const res = await GET(request, params);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.summary).toEqual({ measurements: 12, moodEntries: 3 });
expect(body.data.owner).toBe("self-hoster");
});
it("404s an unknown backup id", async () => {
findUniqueMock.mockResolvedValue(null);
await expect(GET(request, params)).rejects.toMatchObject({ status: 404 });
});
it("degrades to an error response when the file cannot be decrypted", async () => {
decryptMock.mockImplementation(() => {
throw new Error("bad key");
});
const res = await GET(request, params);
expect(res.status).toBe(500);
});
it("422s a file that fails schema validation", async () => {
parseMock.mockImplementation(() => {
throw new Error("nope");
});
const res = await GET(request, params);
expect(res.status).toBe(422);
});
});
@@ -0,0 +1,79 @@
/**
* v1.37.20 — restore preview: what a backup file contains, BEFORE the
* operator confirms a restore.
*
* The restore dialog used to ask for the typed confirmation on nothing but
* a filename and a date; the counts only appeared in the audit row after
* the rows were already replaced. This read decrypts and schema-validates
* the stored file exactly the way the restore itself does (same decrypt,
* same `parseBackupPayload`, same compatibility gate) and answers with the
* `summarizeBackup` counts — so what the operator confirms is what the
* restore will write, derived from the same code path. Read-only; nothing
* is touched.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { decrypt } from "@/lib/crypto";
import { annotate } from "@/lib/logging/context";
import {
isCompatibleSchemaVersion,
parseBackupPayload,
summarizeBackup,
} from "@/lib/validations/backup";
export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user: admin } = await requireAdmin();
void admin;
const { id } = await params;
const backup = await prisma.dataBackup.findUnique({
where: { id },
include: { user: { select: { id: true, username: true } } },
});
if (!backup) {
throw new HttpError(404, "Backup not found");
}
let plaintext: string;
try {
plaintext = decrypt(backup.data);
} catch {
return apiError("Failed to decrypt backup payload", 500);
}
let payload;
try {
payload = parseBackupPayload(plaintext);
} catch {
return apiError("Backup payload failed schema validation", 422);
}
if (!isCompatibleSchemaVersion(payload.schemaVersion)) {
return apiError(
`Backup schema version ${payload.schemaVersion} is not restorable by this release`,
422,
);
}
const summary = summarizeBackup(payload);
annotate({
action: { name: "admin.backups.summary" },
meta: { backupId: id, ownerId: backup.userId },
});
return apiSuccess({
summary,
owner: backup.user?.username ?? null,
createdAt: backup.createdAt.toISOString(),
});
},
);
@@ -22,8 +22,11 @@ import { updateCustomMetricEntrySchema } from "@/lib/validations/custom-metrics"
*
* PATCH applies a partial edit (`data` built field-by-field; an explicit `null`
* on `note` clears it, an omitted key leaves the column untouched). DELETE
* hard-deletes the value. Every read/write is narrowed by both `userId` and the
* parent `customMetricId`; a cross-user or mismatched-parent id surfaces as 404.
* tombstones the value (v1.37.20, A3-11 — undo parity with every peer entry
* surface; the sibling `restore` route clears the tombstone for the toast's
* Undo). Every read/write is narrowed by both `userId` and the parent
* `customMetricId`; a cross-user, tombstoned or mismatched-parent id surfaces
* as 404.
*/
type RouteParams = { params: Promise<{ id: string; entryId: string }> };
@@ -34,7 +37,12 @@ export const PATCH = apiHandler(
const { id, entryId } = await params;
const existing = await prisma.customMetricEntry.findFirst({
where: { id: entryId, userId: user.id, customMetricId: id },
where: {
id: entryId,
userId: user.id,
customMetricId: id,
deletedAt: null,
},
});
if (!existing) {
return apiError("Custom metric entry not found", 404);
@@ -104,13 +112,22 @@ export const DELETE = apiHandler(
const { id, entryId } = await params;
const existing = await prisma.customMetricEntry.findFirst({
where: { id: entryId, userId: user.id, customMetricId: id },
where: {
id: entryId,
userId: user.id,
customMetricId: id,
deletedAt: null,
},
});
if (!existing) {
return apiError("Custom metric entry not found", 404);
}
await prisma.customMetricEntry.delete({ where: { id: entryId } });
// Tombstone, not a hard delete: the Undo toast's restore clears it.
await prisma.customMetricEntry.update({
where: { id: entryId },
data: { deletedAt: new Date() },
});
await auditLog("customMetricEntry.delete", {
userId: user.id,
@@ -0,0 +1,173 @@
/**
* v1.37.20 (A3-11) — custom-metric entries move from hard delete to the
* tombstone + Undo contract every peer entry surface carries.
*
* Watched red: before the conversion the DELETE verb called
* `customMetricEntry.delete` and no restore route existed — the first two
* suites below fail against that state (no `update` with a `deletedAt`
* stamp; module not found), which is the proof the assertions bite.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
customMetricEntry: {
findFirst: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
delete: vi.fn(),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserCorrelationPatterns: vi.fn(),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { DELETE } from "../[entryId]/route";
import { POST as RESTORE } from "../restore/route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "u", role: "USER" as const, locale: "en" },
};
const ENTRY = {
id: "e-1",
userId: "user-1",
customMetricId: "cm-1",
value: 42,
unit: "kg",
measuredAt: new Date("2026-06-10T00:00:00.000Z"),
note: null,
createdAt: new Date("2026-06-10T00:00:00.000Z"),
deletedAt: null,
};
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
});
describe("DELETE /api/custom-metrics/[id]/entries/[entryId]", () => {
it("tombstones instead of hard-deleting", async () => {
vi.mocked(prisma.customMetricEntry.findFirst).mockResolvedValue(
ENTRY as never,
);
vi.mocked(prisma.customMetricEntry.update).mockResolvedValue({} as never);
const res = await DELETE(
new NextRequest("http://localhost/api/custom-metrics/cm-1/entries/e-1", {
method: "DELETE",
}),
{ params: Promise.resolve({ id: "cm-1", entryId: "e-1" }) },
);
expect(res.status).toBe(200);
expect(prisma.customMetricEntry.delete).not.toHaveBeenCalled();
const call = vi.mocked(prisma.customMetricEntry.update).mock.calls[0][0];
expect(call.where).toEqual({ id: "e-1" });
expect(call.data.deletedAt).toBeInstanceOf(Date);
});
it("404s an already-tombstoned entry (the lookup filters the tombstone)", async () => {
vi.mocked(prisma.customMetricEntry.findFirst).mockResolvedValue(null);
const res = await DELETE(
new NextRequest("http://localhost/api/custom-metrics/cm-1/entries/e-1", {
method: "DELETE",
}),
{ params: Promise.resolve({ id: "cm-1", entryId: "e-1" }) },
);
expect(res.status).toBe(404);
const where = vi.mocked(prisma.customMetricEntry.findFirst).mock
.calls[0]![0]!.where!;
expect(where.deletedAt).toBeNull();
expect(prisma.customMetricEntry.update).not.toHaveBeenCalled();
});
});
describe("POST /api/custom-metrics/[id]/entries/restore", () => {
function restoreRequest(body: unknown): NextRequest {
return new NextRequest(
"http://localhost/api/custom-metrics/cm-1/entries/restore",
{
method: "POST",
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
},
);
}
it("clears the tombstone scoped to owner + parent metric", async () => {
vi.mocked(prisma.customMetricEntry.updateMany).mockResolvedValue({
count: 1,
} as never);
const res = await RESTORE(restoreRequest({ ids: ["e-1"] }), {
params: Promise.resolve({ id: "cm-1" }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data).toEqual({ restored: 1 });
const call = vi.mocked(prisma.customMetricEntry.updateMany).mock
.calls[0][0];
expect(call.where).toEqual({
id: { in: ["e-1"] },
userId: "user-1",
customMetricId: "cm-1",
deletedAt: { not: null },
});
expect(call.data).toEqual({ deletedAt: null });
});
it("answers restored: 0 for foreign / live ids — a silent no-op, not a leak", async () => {
vi.mocked(prisma.customMetricEntry.updateMany).mockResolvedValue({
count: 0,
} as never);
const res = await RESTORE(restoreRequest({ ids: ["not-mine"] }), {
params: Promise.resolve({ id: "cm-1" }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data).toEqual({ restored: 0 });
});
it("422s an empty id list", async () => {
const res = await RESTORE(restoreRequest({ ids: [] }), {
params: Promise.resolve({ id: "cm-1" }),
});
expect(res.status).toBe(422);
expect(prisma.customMetricEntry.updateMany).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,79 @@
/**
* `POST /api/custom-metrics/{id}/entries/restore` — un-tombstone for the
* entry delete-Undo affordance (v1.37.20, A3-11).
*
* Body: `{ ids: string[] }` — 1..200 entry ids, scoped to the caller and the
* parent metric.
*
* Clears `deletedAt` on every owned, currently-tombstoned row of THIS metric
* in one `updateMany`, so the value re-surfaces in normal reads. A forged /
* foreign / live / mismatched-parent id is a silent no-op (never a 404
* existence leak): the mutation `where` pins `userId` and `customMetricId`,
* so it only ever touches the caller's rows. Mirrors `/api/labs/restore`.
* Same session auth as the delete beside it — custom-metric entry writes are
* not delegable.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
const MAX_IDS_PER_BATCH = 200;
const restoreSchema = z.object({
ids: z.array(z.string().min(1)).min(1).max(MAX_IDS_PER_BATCH),
});
type RouteParams = { params: Promise<{ id: string }> };
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = restoreSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
const { count } = await prisma.customMetricEntry.updateMany({
where: {
id: { in: parsed.data.ids },
userId: user.id,
customMetricId: id,
deletedAt: { not: null },
},
data: { deletedAt: null },
});
if (count > 0) invalidateUserCorrelationPatterns(user.id);
await auditLog("customMetricEntry.restore", {
userId: user.id,
ipAddress: getClientIp(request),
details: { customMetricId: id, restored: count },
});
annotate({
action: { name: "custom-metric.entry.restore" },
meta: { customMetricId: id, restored: count },
});
return apiSuccess({ restored: count });
},
);
@@ -57,7 +57,7 @@ export const GET = apiHandler(
}
const { limit, offset, sortDir } = parsed.data;
const where = { userId: user.id, customMetricId: id };
const where = { userId: user.id, customMetricId: id, deletedAt: null };
const [rows, total] = await Promise.all([
prisma.customMetricEntry.findMany({
+4 -3
View File
@@ -36,11 +36,12 @@ export const GET = apiHandler(async () => {
orderBy: { name: "asc" },
include: {
entries: {
where: { deletedAt: null },
orderBy: { measuredAt: "desc" },
take: 1,
select: { value: true, unit: true, measuredAt: true },
},
_count: { select: { entries: true } },
_count: { select: { entries: { where: { deletedAt: null } } } },
},
});
@@ -164,12 +165,12 @@ async function postCustomMetric(request: NextRequest) {
if (existing) {
const [latestEntry, count] = await Promise.all([
prisma.customMetricEntry.findFirst({
where: { userId: user.id, customMetricId: created.id },
where: { userId: user.id, customMetricId: created.id, deletedAt: null },
orderBy: { measuredAt: "desc" },
select: { value: true, unit: true, measuredAt: true },
}),
prisma.customMetricEntry.count({
where: { userId: user.id, customMetricId: created.id },
where: { userId: user.id, customMetricId: created.id, deletedAt: null },
}),
]);
latest = latestEntry ?? null;
@@ -967,3 +967,93 @@ describe("DELETE /api/dashboard/widgets — web-owned reset scope", () => {
expect(body.data.updatedAt).toBe(advanced.toISOString());
});
});
describe("dashboard widgets — hero primary content", () => {
const weightOnly = [
{ id: "weight" as const, visible: true, tileVisible: true, order: 0 },
];
it("accepts and persists hero: 'reminders'", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({ version: 1, widgets: weightOnly, hero: "reminders" }),
);
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: { dashboardWidgetsJson: { hero?: string } };
};
expect(updateArg.data.dashboardWidgetsJson.hero).toBe("reminders");
const body = (await res.json()) as { data: { hero?: string } };
expect(body.data.hero).toBe("reminders");
});
it("rejects a hero value outside the closed enum with 422", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({ version: 1, widgets: weightOnly, hero: "banner" }),
);
expect(res.status).toBe(422);
expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled();
});
it("keeps the stored hero choice when the client omits the field", async () => {
// A layout save from a client that predates the field (the native
// client, or a stale web tab) must not silently reset the choice.
const stored: DashboardLayout = serializeDashboardLayout({
version: 1,
widgets: weightOnly,
hero: "reminders",
});
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: weightOnly }));
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: { dashboardWidgetsJson: { hero?: string } };
};
expect(updateArg.data.dashboardWidgetsJson.hero).toBe("reminders");
});
it("honours an explicitly sent 'score' over the stored 'reminders'", async () => {
const stored: DashboardLayout = serializeDashboardLayout({
version: 1,
widgets: weightOnly,
hero: "reminders",
});
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: weightOnly, hero: "score" }),
);
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: { dashboardWidgetsJson: { hero?: string } };
};
// "score" is the default and serializes as an omitted field.
expect(updateArg.data.dashboardWidgetsJson.hero).toBeUndefined();
const body = (await res.json()) as { data: { hero?: string } };
expect(body.data.hero).toBeUndefined();
});
});
+10
View File
@@ -31,6 +31,7 @@ import {
MAX_SELECTED_SCORE_RINGS,
HERO_RING_IDS,
MAX_HERO_RING_ORDER,
HERO_PRIMARY_CONTENTS,
layoutNeedsPreserveRead,
mergePreservedLayoutFields,
type DashboardLayout,
@@ -158,6 +159,15 @@ const layoutSchema = z.object({
.array(z.enum(PRIORITY_ITEM_KINDS))
.max(PRIORITY_ITEM_KINDS.length)
.optional(),
// Hero primary content — what the dashboard hero card leads with:
// "score" (default) or "reminders" (the worth-a-look rail promoted into
// the hero slot). This widens the stored `dashboardWidgetsJson` shape
// ADDITIVELY: the field is optional on the wire and omitted from storage
// at the default, so the native client (which mirrors this blob) can
// keep round-tripping layouts untouched until it learns the field.
// Same preserve-when-absent contract as `comparisonBaseline`; the
// resolver drops unknown stored values back to "score" on read.
hero: z.enum(HERO_PRIMARY_CONTENTS).optional(),
// v1.32.16 (issue #581) — the optimistic-concurrency base token used to
// live here. v1.32.21 (R5a) moved it to the shared `takeBaseToken` helper,
// which strips it BEFORE this parse: the token is transport, not a layout
@@ -38,6 +38,10 @@ vi.mock("@/lib/db", () => ({
encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
// The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the
// same reason as the visit tables above.
measurementReminder: { findMany: vi.fn().mockResolvedValue([]) },
measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) },
// v1.15.0 — cycle tables read by the full-backup helper.
cycleProfile: { findUnique: vi.fn() },
menstrualCycle: { findMany: vi.fn() },
@@ -121,6 +125,10 @@ beforeEach(() => {
vi.mocked(prisma.vaccinationDocumentLink.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.measurementReminder.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurementReminderEvent.findMany).mockResolvedValue(
[] as never,
);
});
afterEach(() => {
@@ -36,6 +36,10 @@ vi.mock("@/lib/db", () => ({
encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
// The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the
// same reason as the visit tables above.
measurementReminder: { findMany: vi.fn().mockResolvedValue([]) },
measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) },
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
@@ -6,9 +6,10 @@ vi.mock("@/lib/db", () => ({
user: { findUnique: vi.fn(), update: vi.fn() },
// v1.4.43 W6 — audit-ledger breadcrumb for validation-failed paths.
auditLog: { create: vi.fn() },
// Per-metric freshness for the APPLE_HEALTH source.
measurement: { groupBy: vi.fn() },
workout: { groupBy: vi.fn() },
// Per-metric freshness + first-run sync progress for the APPLE_HEALTH
// source (#778).
measurement: { groupBy: vi.fn(), aggregate: vi.fn() },
workout: { groupBy: vi.fn(), aggregate: vi.fn() },
},
toJson: <T>(v: T) => v,
}));
@@ -54,6 +55,14 @@ beforeEach(() => {
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never);
vi.mocked(prisma.workout.groupBy).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.aggregate).mockResolvedValue({
_count: { _all: 0 },
_min: { measuredAt: null },
} as never);
vi.mocked(prisma.workout.aggregate).mockResolvedValue({
_count: { _all: 0 },
_min: { startedAt: null },
} as never);
});
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
@@ -227,6 +236,10 @@ describe("GET /api/integrations/healthkit — sync health", () => {
lastSeenAt: string;
stale: boolean;
}>;
syncProgress: {
recordsAccepted: number;
oldestMeasuredAt: string | null;
} | null;
};
async function read(): Promise<Body> {
@@ -327,6 +340,43 @@ describe("GET /api/integrations/healthkit — sync health", () => {
expect(body.lastBackgroundSyncAt).toBeNull();
});
// #778 — a first-run backfill was invisible from the web: nothing said how
// many records had arrived or how far back they reached. The GET now carries
// the two figures the server actually holds.
it("publishes the accepted-row count and the oldest instant reached", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
healthKitConfigJson: null,
healthKitLastSyncedAt: ago(60 * 60 * 1000),
} as never);
vi.mocked(prisma.measurement.aggregate).mockResolvedValue({
_count: { _all: 4200 },
_min: { measuredAt: new Date("2019-05-04T06:00:00.000Z") },
} as never);
vi.mocked(prisma.workout.aggregate).mockResolvedValue({
_count: { _all: 12 },
_min: { startedAt: new Date("2021-01-01T09:00:00.000Z") },
} as never);
const body = await read();
expect(body.syncProgress).toEqual({
recordsAccepted: 4212,
oldestMeasuredAt: "2019-05-04T06:00:00.000Z",
});
});
it("degrades to a null progress summary rather than failing the config read", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
healthKitConfigJson: null,
healthKitLastSyncedAt: ago(DAY),
} as never);
vi.mocked(prisma.measurement.aggregate).mockRejectedValue(
new Error("aggregate hiccup"),
);
const body = await read();
expect(body.syncProgress).toBeNull();
expect(body.syncHealth.verdict).toBe("fresh");
});
it("degrades to no per-metric data rather than failing the config read", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
healthKitConfigJson: null,
@@ -20,6 +20,7 @@ import {
import { annotate } from "@/lib/logging/context";
import { prisma, toJson } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { getAppleHealthSyncProgress } from "@/lib/integrations/apple-health-progress";
import { getSourceFreshness } from "@/lib/integrations/metric-freshness";
import {
classifyMetricFreshness,
@@ -168,6 +169,16 @@ export const GET = apiHandler(async () => {
const samples = await getSourceFreshness(user.id, "APPLE_HEALTH").catch(
() => [],
);
// Issue #778 — a first-run backfill used to be invisible from the web: the
// card said "last sync 2 minutes ago" while giving no sense of how much had
// arrived or how far back it reached. These two figures (row count + oldest
// instant) are everything the server actually knows about backfill
// progress; the phone's queue and throttle state stay on the phone and are
// not guessed at. Fail-soft for the same reason the freshness read is —
// a progress line is never worth 500-ing the config the iOS client needs.
const syncProgress = await getAppleHealthSyncProgress(user.id).catch(
() => null,
);
return apiSuccess({
entries,
@@ -176,6 +187,7 @@ export const GET = apiHandler(async () => {
lastBackgroundSyncAt,
syncHealth,
metricFreshness: classifyMetricFreshness(samples, syncHealth.verdict),
syncProgress,
});
});
@@ -0,0 +1,314 @@
/**
* v1.37.20 (#223 / iOS #68) — the skip, snooze and history routes.
*
* What this suite pins, per the feature's design:
*
* - 404 for a foreign or ENCOUNTER-origin reminder on all three routes
* (the refusal lives in the lookup, before any write).
* - A SCREENING reminder is skippable and snoozable — deliberately NO 409.
* The satisfy-side 409 guards against CLAIMED fulfilment without an
* assessment; skip and snooze claim the opposite, so refusing them here
* would only push people back to deleting the reminder. This test is the
* pinned boundary between the two rules.
* - Skip: forward-only no-op behind an existing cursor; the write never
* carries `lastSatisfiedAt` (THE invariant); the SKIPPED ledger row.
* - Snooze: the named calendar day resolves to the notifyHour in the
* profile timezone; both cursors move to the SAME instant; a past date
* and a beyond-cap date answer 422; the cadence fields stay untouched.
* - History: paginated newest-first with the meta triple.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
requireRecordAuth: vi.fn(async () => ({
user: { id: "u1", locale: "en" },
actor: { id: "u1", locale: "en" },
grantId: null,
})),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
const findFirstMock = vi.fn();
const updateMock = vi.fn();
const updateManyMock = vi.fn();
const findUniqueOrThrowMock = vi.fn();
const findUserMock = vi.fn();
const eventCreateMock = vi.fn();
const eventFindManyMock = vi.fn();
const eventCountMock = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
measurementReminder: {
findFirst: (...a: unknown[]) => findFirstMock(...a),
update: (...a: unknown[]) => updateMock(...a),
updateMany: (...a: unknown[]) => updateManyMock(...a),
findUniqueOrThrow: (...a: unknown[]) => findUniqueOrThrowMock(...a),
},
measurementReminderEvent: {
create: (...a: unknown[]) => eventCreateMock(...a),
findMany: (...a: unknown[]) => eventFindManyMock(...a),
count: (...a: unknown[]) => eventCountMock(...a),
},
user: { findUnique: (...a: unknown[]) => findUserMock(...a) },
},
}));
import { POST as SKIP } from "../skip/route";
import { POST as SNOOZE } from "../snooze/route";
import { GET as HISTORY } from "../history/route";
const NOW = new Date("2026-06-15T08:00:00.000Z");
const BASE_ROW = {
id: "r1",
userId: "u1",
label: "Blutdruck messen",
measurementType: "BLOOD_PRESSURE_SYS",
intervalDays: 30,
rrule: null as string | null,
anchorDate: null as Date | null,
endsOn: null,
origin: "VORSORGE",
notifyHour: 9,
location: null,
nextDueAt: new Date("2026-06-15T07:00:00Z"),
lastSatisfiedAt: null as Date | null,
snoozedUntil: null as Date | null,
lastSkippedAt: null as Date | null,
skipCount: 0,
enabled: true,
vaccinationAntigen: null,
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
deletedAt: null,
};
function params(id = "r1") {
return { params: Promise.resolve({ id }) };
}
function skipRequest(): NextRequest {
return new NextRequest("http://localhost/api/measurement-reminders/r1/skip", {
method: "POST",
});
}
function snoozeRequest(body: unknown): NextRequest {
return new NextRequest(
"http://localhost/api/measurement-reminders/r1/snooze",
{
method: "POST",
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
},
);
}
function historyRequest(query = ""): NextRequest {
return new NextRequest(
`http://localhost/api/measurement-reminders/r1/history${query}`,
{ method: "GET" },
);
}
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
findFirstMock.mockResolvedValue({ ...BASE_ROW });
findUserMock.mockResolvedValue({ timezone: "Europe/Berlin" });
updateManyMock.mockResolvedValue({ count: 1 });
updateMock.mockResolvedValue({ ...BASE_ROW });
findUniqueOrThrowMock.mockResolvedValue({ ...BASE_ROW });
eventCreateMock.mockResolvedValue({ id: "evt-1" });
eventFindManyMock.mockResolvedValue([]);
eventCountMock.mockResolvedValue(0);
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
describe("POST /api/measurement-reminders/[id]/skip", () => {
it("404s a foreign reminder without touching it", async () => {
findFirstMock.mockResolvedValue({ ...BASE_ROW, userId: "someone-else" });
const res = await SKIP(skipRequest(), params());
expect(res.status).toBe(404);
expect(updateManyMock).not.toHaveBeenCalled();
});
it("404s an ENCOUNTER reminder via the lookup filter", async () => {
// The route's findFirst carries `origin: { not: "ENCOUNTER" }`, so the
// DB answers null for an appointment row. Mirror that here.
findFirstMock.mockResolvedValue(null);
const res = await SKIP(skipRequest(), params());
expect(res.status).toBe(404);
const where = findFirstMock.mock.calls[0][0].where;
expect(where.origin).toEqual({ not: "ENCOUNTER" });
expect(updateManyMock).not.toHaveBeenCalled();
});
it("skips a SCREENING reminder — the satisfy-side 409 does not apply", async () => {
findFirstMock.mockResolvedValue({
...BASE_ROW,
measurementType: "PHQ9_SCORE",
});
const res = await SKIP(skipRequest(), params());
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.skipped).toBe(true);
});
it("stamps the skip without ever touching lastSatisfiedAt", async () => {
const res = await SKIP(skipRequest(), params());
expect(res.status).toBe(200);
const { data } = updateManyMock.mock.calls[0][0];
expect(data.lastSkippedAt).toEqual(NOW);
expect(data.skipCount).toEqual({ increment: 1 });
expect(data.snoozedUntil).toBeNull();
expect("lastSatisfiedAt" in data).toBe(false);
// Rolling 30d cadence restarts from the skip instant.
expect(data.nextDueAt.getTime()).toBeGreaterThan(
NOW.getTime() + 29 * 24 * 60 * 60 * 1000,
);
});
it("appends the SKIPPED ledger row", async () => {
await SKIP(skipRequest(), params());
expect(eventCreateMock).toHaveBeenCalledTimes(1);
const { data } = eventCreateMock.mock.calls[0][0];
expect(data).toMatchObject({
userId: "u1",
reminderId: "r1",
kind: "SKIPPED",
source: "skip",
});
});
it("answers skipped=false on a forward-only no-op (double skip)", async () => {
findFirstMock.mockResolvedValue({
...BASE_ROW,
// A cursor already ahead of the clock — the primitive refuses in
// memory, before any write.
lastSkippedAt: new Date("2026-06-16T09:00:00Z"),
});
const res = await SKIP(skipRequest(), params());
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.skipped).toBe(false);
expect(updateManyMock).not.toHaveBeenCalled();
expect(eventCreateMock).not.toHaveBeenCalled();
});
});
describe("POST /api/measurement-reminders/[id]/snooze", () => {
it("404s an ENCOUNTER reminder via the lookup filter", async () => {
findFirstMock.mockResolvedValue(null);
const res = await SNOOZE(snoozeRequest({ until: "2026-09-01" }), params());
expect(res.status).toBe(404);
const where = findFirstMock.mock.calls[0][0].where;
expect(where.origin).toEqual({ not: "ENCOUNTER" });
});
it("snoozes a SCREENING reminder without a 409", async () => {
findFirstMock.mockResolvedValue({
...BASE_ROW,
measurementType: "GAD7_SCORE",
});
const res = await SNOOZE(snoozeRequest({ until: "2026-09-01" }), params());
expect(res.status).toBe(200);
});
it("resolves the named day to the notifyHour in the profile timezone and moves both cursors together", async () => {
const res = await SNOOZE(snoozeRequest({ until: "2026-09-01" }), params());
expect(res.status).toBe(200);
const { data } = updateMock.mock.calls[0][0];
// 2026-09-01 09:00 Europe/Berlin (CEST, UTC+2) = 07:00Z. This is the
// DST-aware wall-clock resolution, not a request-time offset.
const expected = new Date("2026-09-01T07:00:00.000Z");
expect(data.snoozedUntil).toEqual(expected);
expect(data.nextDueAt).toEqual(expected);
// The cadence and its cursors stay untouched.
expect("lastSatisfiedAt" in data).toBe(false);
expect("lastSkippedAt" in data).toBe(false);
expect("intervalDays" in data).toBe(false);
expect("anchorDate" in data).toBe(false);
});
it("422s a past or same-day date", async () => {
const res = await SNOOZE(snoozeRequest({ until: "2026-06-15" }), params());
expect(res.status).toBe(422);
expect(updateMock).not.toHaveBeenCalled();
});
it("422s a date beyond the five-year cap", async () => {
const res = await SNOOZE(snoozeRequest({ until: "2031-07-01" }), params());
expect(res.status).toBe(422);
expect(updateMock).not.toHaveBeenCalled();
});
it("422s a malformed body through the multi-issue envelope", async () => {
const res = await SNOOZE(snoozeRequest({ until: "not-a-date" }), params());
expect(res.status).toBe(422);
});
});
describe("GET /api/measurement-reminders/[id]/history", () => {
it("404s an ENCOUNTER reminder via the lookup filter", async () => {
findFirstMock.mockResolvedValue(null);
const res = await HISTORY(historyRequest(), params());
expect(res.status).toBe(404);
const where = findFirstMock.mock.calls[0][0].where;
expect(where.origin).toEqual({ not: "ENCOUNTER" });
});
it("pages the ledger newest first with the meta triple", async () => {
eventFindManyMock.mockResolvedValue([
{
id: "e2",
kind: "SKIPPED",
occurredAt: new Date("2026-06-10T09:00:00Z"),
onTime: false,
source: "skip",
createdAt: new Date("2026-06-10T09:00:00Z"),
},
{
id: "e1",
kind: "SATISFIED",
occurredAt: new Date("2026-05-10T09:00:00Z"),
onTime: true,
source: "manual",
createdAt: new Date("2026-05-10T09:00:00Z"),
},
]);
eventCountMock.mockResolvedValue(2);
const res = await HISTORY(historyRequest("?limit=10&offset=0"), params());
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.events).toHaveLength(2);
expect(body.data.events[0]).toMatchObject({
id: "e2",
kind: "SKIPPED",
onTime: false,
source: "skip",
});
expect(body.data.meta).toEqual({ total: 2, limit: 10, offset: 0 });
const query = eventFindManyMock.mock.calls[0][0];
expect(query.orderBy).toEqual({ occurredAt: "desc" });
expect(query.where).toEqual({ reminderId: "r1", userId: "u1" });
});
it("422s an out-of-range limit", async () => {
const res = await HISTORY(historyRequest("?limit=1000"), params());
expect(res.status).toBe(422);
});
});
@@ -77,7 +77,13 @@ export const POST = apiHandler(
// Forward-only: a completion strictly after any prior satisfy advances
// the row; a completion at/behind the last satisfy is a no-op. Either
// way no notification is emitted — the primitive only stamps + reschedules.
const result = await satisfyReminder(prisma, existing, timezone, now);
const result = await satisfyReminder(
prisma,
existing,
timezone,
now,
"manual",
);
const updated = await prisma.measurementReminder.findUniqueOrThrow({
where: { id },
@@ -0,0 +1,72 @@
/**
* v1.37.20 (iOS #68) — the completion ledger for one Vorsorge reminder,
* paginated, newest first.
*
* Every satisfy (from any path — manual, auto-resolve, Telegram,
* vaccination booster, visit close) and every skip appends one row via the
* shared primitives; this route only reads. History begins at the release
* that introduced the ledger: the single-cursor engine holds nothing to
* backfill from, which the iOS #68 thread records as agreed.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { listReminderHistorySchema } from "@/lib/validations/measurement-reminders";
type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
// READ — the same level as the reminder list this history hangs off.
const { user } = await requireRecordAuth("read", "measurements");
const { id } = await params;
const searchParams = Object.fromEntries(request.nextUrl.searchParams);
const parsed = listReminderHistorySchema.safeParse(searchParams);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
const { limit, offset } = parsed.data;
// An appointment reminder is not addressable by this family — 404, the
// same refusal every by-id sibling makes.
const existing = await prisma.measurementReminder.findFirst({
where: { id, deletedAt: null, origin: { not: "ENCOUNTER" } },
select: { id: true, userId: true },
});
if (!existing || existing.userId !== user.id) {
return apiError("Measurement reminder not found", 404);
}
const where = { reminderId: id, userId: user.id };
const [rows, total] = await Promise.all([
prisma.measurementReminderEvent.findMany({
where,
orderBy: { occurredAt: "desc" },
take: limit,
skip: offset,
}),
prisma.measurementReminderEvent.count({ where }),
]);
annotate({
action: { name: "measurement-reminders.history" },
meta: { reminderId: id, total },
});
return apiSuccess({
events: rows.map((row) => ({
id: row.id,
kind: row.kind,
occurredAt: row.occurredAt.toISOString(),
onTime: row.onTime,
source: row.source,
createdAt: row.createdAt.toISOString(),
})),
meta: { total, limit, offset },
});
},
);
@@ -189,6 +189,9 @@ export const PATCH = apiHandler(
? existing.lastSatisfiedAt
: now;
updateData.nextDueAt = computeReminderNextDueAt(merged, timezone, after);
// v1.37.20 (#223) — a cadence edit recomputes `nextDueAt`, so it clears
// the snooze cursor: the cycle the snooze pushed back no longer exists.
updateData.snoozedUntil = null;
const updated = await prisma.measurementReminder.update({
where: { id },
@@ -64,7 +64,7 @@ export const POST = apiHandler(
// auto-resolve and the eventful worker use. A manual "Erledigt" is
// always strictly after any prior satisfy, so the forward-only guard
// advances it.
await satisfyReminder(prisma, existing, timezone, now);
await satisfyReminder(prisma, existing, timezone, now, "manual");
const updated = await prisma.measurementReminder.findUniqueOrThrow({
where: { id },
@@ -0,0 +1,77 @@
/**
* v1.37.20 (#223) — honestly skip a Vorsorge reminder's current due cycle.
*
* No body: the skip instant is always the server clock. The interval
* restarts from the skip (rolling: skip + N days at the notify hour; rrule:
* next occurrence strictly after the skip), `lastSkippedAt` is stamped,
* `skipCount` incremented, any snooze cleared, and one SKIPPED row lands in
* the completion ledger. `lastSatisfiedAt` is NEVER touched — a skip is the
* honest alternative to deleting or ignoring a reminder, not a completion.
*
* Screening reminders (PHQ-9 / GAD-7 / WHO-5 / SCI) ARE skippable — the
* satisfy-side 409 guards against CLAIMED fulfilment without an assessment;
* a skip claims the opposite, so refusing it here would only push people
* back to deleting the reminder.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { skipReminder } from "@/lib/measurement-reminders/satisfy";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
type RouteParams = { params: Promise<{ id: string }> };
const DEFAULT_TIMEZONE = "Europe/Berlin";
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
// MANAGE — the same level as satisfy/complete: deciding to let a cycle
// go re-anchors a schedule this level already admits editing.
const { user } = await requireRecordAuth("manage", "measurements");
const { id } = await params;
// An appointment reminder is not addressable here. It belongs to a visit,
// is managed through the visit routes, and sits in a different sharing
// domain — so this family treats one as not found rather than acting on
// it. Skipping an appointment would move a date the visit record still
// believes it owns.
const existing = await prisma.measurementReminder.findFirst({
where: { id, deletedAt: null, origin: { not: "ENCOUNTER" } },
});
if (!existing || existing.userId !== user.id) {
return apiError("Measurement reminder not found", 404);
}
const userRow = await prisma.user.findUnique({
where: { id: user.id },
select: { timezone: true },
});
const timezone = userRow?.timezone || DEFAULT_TIMEZONE;
const result = await skipReminder(prisma, existing, timezone, new Date());
const updated = await prisma.measurementReminder.findUniqueOrThrow({
where: { id },
});
await auditLog("measurementReminder.skip", {
userId: user.id,
ipAddress: getClientIp(request),
details: { reminderId: id, skipped: result.skipped },
});
annotate({
action: { name: "measurement-reminders.skip" },
meta: { reminderId: id, skipped: result.skipped },
});
return apiSuccess({
skipped: result.skipped,
reminder: toMeasurementReminderDto(updated),
});
},
);
@@ -0,0 +1,134 @@
/**
* v1.37.20 (#223) — push a Vorsorge reminder's current due date back to a
* named calendar day.
*
* Body `{ until: "YYYY-MM-DD" }`, day precision on purpose: the server
* resolves the day to the reminder's notifyHour in the profile timezone, so
* no client-supplied instant ever anchors the cadence. Both `snoozedUntil`
* and `nextDueAt` are set to the SAME resolved instant — every due-state
* consumer (digest, dashboard card, cron, AI block) moves with it without
* knowing snooze exists, and the cursor self-expires when the clock passes
* it. The regular interval is untouched: `lastSatisfiedAt`,
* `lastSkippedAt` and the anchor stay where they are, so after the snoozed
* cycle the cadence resumes exactly as configured. Repeated snoozes: the
* last one wins.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
returnAllZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
import { snoozeMeasurementReminderSchema } from "@/lib/validations/measurement-reminders";
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
type RouteParams = { params: Promise<{ id: string }> };
const DEFAULT_TIMEZONE = "Europe/Berlin";
/** Furthest a snooze may reach: five years from now. */
const SNOOZE_CAP_MS = 5 * 365.25 * 24 * 60 * 60 * 1000;
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
// MANAGE — the same level as satisfy/complete: pushing a due date back
// re-anchors a schedule this level already admits editing.
const { user } = await requireRecordAuth("manage", "measurements");
const { id } = await params;
const body = await safeJson(request);
if (body.error) return body.error;
const parsed = snoozeMeasurementReminderSchema.safeParse(body.data);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
// An appointment reminder is not addressable here — see the sibling
// routes; snoozing one would move a date the visit record still owns.
const existing = await prisma.measurementReminder.findFirst({
where: { id, deletedAt: null, origin: { not: "ENCOUNTER" } },
});
if (!existing || existing.userId !== user.id) {
return apiError("Measurement reminder not found", 404);
}
const userRow = await prisma.user.findUnique({
where: { id: user.id },
select: { timezone: true },
});
const timezone = userRow?.timezone || DEFAULT_TIMEZONE;
// Resolve the named local day to its notifyHour instant in the profile
// timezone (DST-settled at the target day, not at the request instant).
const [year, month, day] = parsed.data.until.split("-").map(Number);
const until = zonedWallClockToUtc(
{ year, month, day, hour: existing.notifyHour, minute: 0, second: 0 },
timezone,
);
// Range gate: at least tomorrow (a snooze to today or the past is a
// no-op wearing a confirmation), at most five years out.
const now = new Date();
const startOfTomorrow = zonedWallClockToUtc(
(() => {
const probe = new Date(now.getTime() + 24 * 60 * 60 * 1000);
const parts = new Intl.DateTimeFormat("sv-SE", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
})
.format(probe)
.split("-")
.map(Number);
return {
year: parts[0],
month: parts[1],
day: parts[2],
hour: 0,
minute: 0,
second: 0,
};
})(),
timezone,
);
if (until.getTime() < startOfTomorrow.getTime()) {
return apiError("Snooze date must be at least tomorrow", 422);
}
if (until.getTime() > now.getTime() + SNOOZE_CAP_MS) {
return apiError("Snooze date must be within five years", 422);
}
// One write, both cursors, same instant. lastSatisfiedAt/lastSkippedAt
// and the cadence fields are deliberately untouched.
await prisma.measurementReminder.update({
where: { id },
data: { snoozedUntil: until, nextDueAt: until },
});
const updated = await prisma.measurementReminder.findUniqueOrThrow({
where: { id },
});
await auditLog("measurementReminder.snooze", {
userId: user.id,
ipAddress: getClientIp(request),
details: { reminderId: id, until: until.toISOString() },
});
annotate({
action: { name: "measurement-reminders.snooze" },
meta: { reminderId: id, until: until.toISOString() },
});
return apiSuccess({ reminder: toMeasurementReminderDto(updated) });
},
);
@@ -0,0 +1,241 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
/**
* `GET /api/mental-health/assessments/[id]` — the per-administration detail
* read that finally consumes `responsesEncrypted` (audit finding: the column
* was write-only; its single reader was key rotation).
*
* Pins the two halves of the contract:
* (a) the owner's detail read decrypts the stored blob and returns the
* per-item answers + the unscored functional follow-up, and
* (b) a decrypt failure (key gap / corruption / shape drift) degrades to
* `items: null` + `itemsUnavailable: true` while the denormalised
* total / band / flag still answer — never a 500 for the whole row
* (the records-DTO fail-soft precedent).
*/
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
mentalHealthAssessment: { findFirst: vi.fn() },
},
}));
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
resolveModuleMap: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
decryptFromBytes: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { prisma } from "@/lib/db";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "testuser",
role: "USER" as const,
locale: "en",
},
};
const callGet = GET as unknown as (
req: NextRequest,
ctx: { params: Promise<{ id: string }> },
) => Promise<Response>;
function makeReq(
id: string,
): [NextRequest, { params: Promise<{ id: string }> }] {
return [
new NextRequest(
new URL(`http://localhost/api/mental-health/assessments/${id}`),
{ method: "GET" },
),
{ params: Promise.resolve({ id }) },
];
}
/** A stored PHQ-9 administration as the select returns it. */
function storedRow(overrides: Record<string, unknown> = {}) {
return {
id: "mha_1",
userId: "user-1",
instrument: "PHQ9",
locale: "en",
version: "standard",
totalScore: 10,
severityBand: "moderate",
item9Flagged: true,
crisisShownAt: new Date("2026-06-28T00:00:00.000Z"),
takenAt: new Date("2026-06-28T00:00:00.000Z"),
createdAt: new Date("2026-06-28T00:00:00.000Z"),
responsesEncrypted: Buffer.from("ciphertext"),
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
});
describe("GET /api/mental-health/assessments/[id]", () => {
it("returns the decrypted per-item answers + functional follow-up for the owner", async () => {
vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue(
storedRow() as never,
);
vi.mocked(decryptFromBytes).mockReturnValue(
JSON.stringify({
items: [1, 1, 1, 1, 1, 1, 1, 1, 2],
functionalDifficulty: 1,
schema: 1,
}),
);
const res = await callGet(...makeReq("mha_1"));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
assessment: {
id: string;
totalScore: number;
items: number[] | null;
functionalDifficulty: number | null;
itemsUnavailable: boolean;
};
};
};
expect(body.data.assessment.id).toBe("mha_1");
expect(body.data.assessment.items).toEqual([1, 1, 1, 1, 1, 1, 1, 1, 2]);
expect(body.data.assessment.functionalDifficulty).toBe(1);
expect(body.data.assessment.itemsUnavailable).toBe(false);
// The denormalised score fields ride along unchanged.
expect(body.data.assessment.totalScore).toBe(10);
});
it("omits functionalDifficulty as null when the stored blob has none", async () => {
vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue(
storedRow({
instrument: "GAD7",
totalScore: 6,
item9Flagged: false,
}) as never,
);
vi.mocked(decryptFromBytes).mockReturnValue(
JSON.stringify({ items: [1, 2, 0, 1, 1, 1, 0], schema: 1 }),
);
const res = await callGet(...makeReq("mha_1"));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
assessment: {
items: number[] | null;
functionalDifficulty: number | null;
};
};
};
expect(body.data.assessment.items).toEqual([1, 2, 0, 1, 1, 1, 0]);
expect(body.data.assessment.functionalDifficulty).toBeNull();
});
it("degrades to items: null + itemsUnavailable on a decrypt failure — never a 500", async () => {
vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue(
storedRow() as never,
);
vi.mocked(decryptFromBytes).mockImplementation(() => {
throw new Error("Unknown encryption key id: v9");
});
const res = await callGet(...makeReq("mha_1"));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
assessment: {
totalScore: number;
items: number[] | null;
itemsUnavailable: boolean;
};
};
};
expect(body.data.assessment.items).toBeNull();
expect(body.data.assessment.itemsUnavailable).toBe(true);
// The score half of the row still answers.
expect(body.data.assessment.totalScore).toBe(10);
});
it("degrades on a malformed or shape-drifted plaintext (wrong item count)", async () => {
vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue(
storedRow() as never,
);
// 3 answers on a stored PHQ-9 row: pairing them to the 9 official item
// texts would mislabel every answer, so the read refuses to guess.
vi.mocked(decryptFromBytes).mockReturnValue(
JSON.stringify({ items: [1, 2, 3], schema: 1 }),
);
const res = await callGet(...makeReq("mha_1"));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
assessment: { items: number[] | null; itemsUnavailable: boolean };
};
};
expect(body.data.assessment.items).toBeNull();
expect(body.data.assessment.itemsUnavailable).toBe(true);
});
it("404s another user's row without leaking its existence", async () => {
vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue(
storedRow({ userId: "user-2" }) as never,
);
const res = await callGet(...makeReq("mha_1"));
expect(res.status).toBe(404);
// The item content never left the guard.
expect(decryptFromBytes).not.toHaveBeenCalled();
});
it("404s a missing row", async () => {
vi.mocked(prisma.mentalHealthAssessment.findFirst).mockResolvedValue(
null as never,
);
const res = await callGet(...makeReq("mha_missing"));
expect(res.status).toBe(404);
});
it("returns the module 403 when the mental-health module is off", async () => {
const { apiError } = await import("@/lib/api-response");
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: false,
response: apiError("disabled", 403, { errorCode: "module.disabled" }),
});
const res = await callGet(...makeReq("mha_1"));
expect(res.status).toBe(403);
expect(prisma.mentalHealthAssessment.findFirst).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,175 @@
/**
* `GET /api/mental-health/assessments/[id]` — one screener administration WITH
* its decrypted per-item answers.
*
* The list route beside this one denormalises total + band + item-9 flag for
* cheap history reads and deliberately never touches `responsesEncrypted`.
* Until this route existed nothing read the blob back at all (its only
* consumer was key rotation), so the stored clinical detail — including the
* PHQ-9 item-9 answer — was invisible to the person it belongs to. This is
* the read side of that write.
*
* GATING — same declaration as the sibling list read (`read`, `mind`), on
* purpose: the mind domain already serves decrypted free text at this level
* (the mood entry's encrypted note on `GET /api/mood-entries/[id]`, the Coach
* reminder notes), and the list read already publishes `item9Flagged` — the
* fact that the self-harm item was answered non-zero. A delegate the owner
* trusted with a mind-scope read grant therefore learns nothing here that is
* categorically beyond what the domain already shows; splitting the per-item
* values onto a stricter gate than the mood diary's prose would be an
* inconsistency, not a protection. The module gate resolves against the
* RECORD, so the surface stays unreachable wherever the owner keeps the
* module off.
*
* DECRYPT FAILURES degrade honestly (the records-DTO fail-soft precedent):
* `items: null` + `itemsUnavailable: true`, with the denormalised score
* fields still answering — a key gap on one row never 500s the read. A
* plaintext whose item count no longer matches the instrument degrades the
* same way, because pairing answers to the wrong official item texts would be
* worse than showing none.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate, getEvent } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import {
INSTRUMENTS,
type InstrumentDefinition,
type InstrumentId,
} from "@/lib/mental-health/instruments";
type RouteParams = { params: Promise<{ id: string }> };
interface DecodedResponses {
/** Per-item answers in presentation order, or null when unreadable. */
items: number[] | null;
/** The unscored PHQ-9 functional follow-up, when it was answered. */
functionalDifficulty: number | null;
itemsUnavailable: boolean;
}
const UNAVAILABLE: DecodedResponses = {
items: null,
functionalDifficulty: null,
itemsUnavailable: true,
};
/**
* Decrypt + decode the stored `{ items, functionalDifficulty?, schema: 1 }`
* blob, fail-soft. Every answer is re-checked against the instrument's own
* ceiling before it is served: the write path validated exactly these bounds,
* so anything outside them is corruption, not data.
*/
function decodeResponses(
buf: Uint8Array,
def: InstrumentDefinition,
): DecodedResponses {
let parsed: unknown;
try {
parsed = JSON.parse(decryptFromBytes(buf));
} catch (err) {
// Undecryptable payload (key gap / corruption): fail soft but log it
// (F-CRYPTO-2) so a systemic key gap surfaces instead of reading blank.
getEvent()?.addWarning(
`assessment responses decrypt failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return UNAVAILABLE;
}
if (typeof parsed !== "object" || parsed === null) return UNAVAILABLE;
const { items, functionalDifficulty } = parsed as {
items?: unknown;
functionalDifficulty?: unknown;
};
const valid =
Array.isArray(items) &&
items.length === def.itemCount &&
items.every(
(v) => Number.isInteger(v) && (v as number) >= 0 && v <= def.itemMax,
);
if (!valid) {
getEvent()?.addWarning(
"assessment responses shape drifted from the instrument definition",
);
return UNAVAILABLE;
}
return {
items: items as number[],
functionalDifficulty:
Number.isInteger(functionalDifficulty) &&
(functionalDifficulty as number) >= 0 &&
(functionalDifficulty as number) <= 3
? (functionalDifficulty as number)
: null,
itemsUnavailable: false,
};
}
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireRecordAuth("read", "mind");
// Opt-in module (default OFF) — the same record-resolved gate as the list.
const gate = await requireModuleEnabled(user.id, "mentalHealth");
if (!gate.enabled) return gate.response;
const { id } = await params;
// Fetch-then-guard against the resolved user (the by-id sibling pattern);
// a soft-deleted row 404s like it does on the list.
const row = await prisma.mentalHealthAssessment.findFirst({
where: { id, deletedAt: null },
select: {
id: true,
userId: true,
instrument: true,
locale: true,
version: true,
totalScore: true,
severityBand: true,
item9Flagged: true,
crisisShownAt: true,
takenAt: true,
createdAt: true,
responsesEncrypted: true,
},
});
if (!row || row.userId !== user.id) {
return apiError("Assessment not found", 404);
}
const def = INSTRUMENTS[row.instrument as InstrumentId];
const decoded = decodeResponses(row.responsesEncrypted, def);
annotate({
action: { name: "mental-health.detail" },
// The instrument + degrade flag only — item answers are NEVER logged.
meta: {
instrument: row.instrument,
items_unavailable: decoded.itemsUnavailable,
},
});
return apiSuccess({
assessment: {
id: row.id,
instrument: row.instrument,
locale: row.locale,
version: row.version,
totalScore: row.totalScore,
severityBand: row.severityBand,
item9Flagged: row.item9Flagged,
crisisShownAt: row.crisisShownAt?.toISOString() ?? null,
takenAt: row.takenAt.toISOString(),
createdAt: row.createdAt.toISOString(),
items: decoded.items,
functionalDifficulty: decoded.functionalDifficulty,
itemsUnavailable: decoded.itemsUnavailable,
},
});
},
);
@@ -13,7 +13,8 @@
* interpretation, no AI ingestion of item content, no third-party alerting.
*
* GET lists the caller's assessments (totals + bands + flags only — item-level
* answers are excluded from this surface by construction).
* answers are excluded from this surface by construction; the by-id sibling
* route is the one read that decrypts them back).
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
+12 -20
View File
@@ -12,7 +12,6 @@ import {
} from "@/components/measurements/measurement-form";
import { MeasurementList } from "@/components/measurements/measurement-list";
import { CustomMetricList } from "@/components/custom-metrics/custom-metric-list";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/ui/page-header";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
@@ -64,18 +63,6 @@ export default function MeasurementsPage() {
const addRequestParam = addParam
? `${addParam}\u0000${returnToParam ?? ""}`
: null;
// v1.18.7 (Wave E) — a `?type=<MEASUREMENT_TYPE>` deep link (e.g. the
// Vorsorge card's "Show measurements") seeds the list's type filter on
// first paint. Captured once via a lazy initializer so the value is
// stable even after the effect below strips the query string; an unknown
// token is dropped (validated against the canonical type-label map), so a
// stale link never traps the user on a broken filter.
const typeParam = searchParams.get("type");
const [initialType] = useState<string | undefined>(() =>
typeParam && typeParam in MEASUREMENT_TYPE_LABEL_KEYS
? typeParam
: undefined,
);
const [dialogOpen, setDialogOpen] = useState(() => initialAdd != null);
const [defaultType, setDefaultType] = useState<string | undefined>(
() => initialAdd ?? undefined,
@@ -118,15 +105,21 @@ export default function MeasurementsPage() {
}
}
// Drop the query string so the back-button leaves the user on
// `/measurements` rather than re-opening the dialog (or re-seeding the
// type filter). The type filter is already seeded into the list's own
// state via `initialType`, so stripping the param doesn't lose it.
// Drop the one-shot `?add=` / `?returnTo=` params so the back-button
// leaves the user on `/measurements` rather than re-opening the dialog.
// Every OTHER param is preserved: the list's filter state (`?type=`,
// `?source=`, …) now lives in the URL (see `measurement-list-filters.ts`)
// and stripping it would lose the filters this page is meant to keep.
// Effect-driven (client-only) because `router.replace` is not callable
// during SSR.
useEffect(() => {
if (addParam || typeParam || returnToParam) router.replace("/measurements");
}, [addParam, typeParam, returnToParam, router]);
if (!addParam && !returnToParam) return;
const next = new URLSearchParams(searchParams.toString());
next.delete("add");
next.delete("returnTo");
const search = next.toString();
router.replace(search ? `/measurements?${search}` : "/measurements");
}, [addParam, returnToParam, searchParams, router]);
useEffect(() => {
if (!isLoading && !isAuthenticated) {
@@ -214,7 +207,6 @@ export default function MeasurementsPage() {
setReturnTo(null);
setDialogOpen(true);
}}
initialType={initialType}
/>
<CustomMetricList />
+3
View File
@@ -967,6 +967,9 @@ export default function DashboardPageClient({
<TodayHero
digest={digestQuery.data}
renderFilteredAllClear={renderFilteredHeroAllClear}
// Server-persisted hero choice — rides the same resolved layout
// as the widget visibility below, so SSR and hydration agree.
primaryContent={layout.hero ?? "score"}
/>
) : !mounted || digestQuery.isLoading ? (
<TodayHeroSkeleton />
@@ -307,6 +307,9 @@ const REMINDER: MeasurementReminder = {
location: null,
nextDueAt: "2026-08-10T09:00:00.000Z",
lastSatisfiedAt: null,
snoozedUntil: null,
lastSkippedAt: null,
skipCount: 0,
enabled: true,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
+124
View File
@@ -68,6 +68,46 @@ import { apiFetch, apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch";
* Mirrors the wipe dialog's pattern but adds the typed gate because the
* blast radius is bigger (re-creates rows, not just deletes).
*/
/**
* v1.37.20 — the sections a restore preview names, in render order. Each
* entry sums one or more `BackupSummary` counters under one human label;
* every counter NOT named here still reaches the operator through the
* trailing "other records" line, so a future summary key can be forgotten
* here without silently vanishing from the preview.
*/
const RESTORE_PREVIEW_SECTIONS: ReadonlyArray<{
labelKey: string;
keys: readonly string[];
}> = [
{ labelKey: "previewMeasurements", keys: ["measurements"] },
{
labelKey: "previewMedications",
keys: ["medications", "intakeEvents", "medicationSideEffects"],
},
{ labelKey: "previewMood", keys: ["moodEntries"] },
{ labelKey: "previewLabs", keys: ["labResults", "biomarkers"] },
{ labelKey: "previewDocuments", keys: ["documents"] },
{ labelKey: "previewWorkouts", keys: ["workouts"] },
{ labelKey: "previewCycles", keys: ["cycles", "cycleDayLogs"] },
{
labelKey: "previewVisits",
keys: ["encounters", "practitioners", "encounterLinks"],
},
{
labelKey: "previewVaccinations",
keys: ["vaccinations", "vaccinationLinks"],
},
{
labelKey: "previewReminders",
keys: ["measurementReminders", "measurementReminderEvents"],
},
{
labelKey: "previewCustomMetrics",
keys: ["customMetrics", "customMetricEntries"],
},
{ labelKey: "previewHealthScores", keys: ["healthScoreRecords"] },
];
function RestoreRowDialog({
row,
pending,
@@ -83,6 +123,39 @@ function RestoreRowDialog({
const [typed, setTyped] = useState("");
const matched = typed.trim() === "RESTORE";
// v1.37.20 — restore preview: fetch what the file contains the moment the
// dialog opens, so the typed confirmation is an informed one. Derived from
// the same decrypt + schema path the restore itself runs. A failed preview
// never blocks the restore — it states its own absence instead.
const preview = useQuery({
queryKey: queryKeys.adminBackupSummary(row.id),
queryFn: () =>
apiGet<{ summary: Record<string, unknown> }>(
`/api/admin/backups/${row.id}/summary`,
),
enabled: open,
staleTime: 60_000,
});
const numericSummary: Record<string, number> = {};
if (preview.data?.summary) {
for (const [key, value] of Object.entries(preview.data.summary)) {
if (typeof value === "number") numericSummary[key] = value;
}
}
const namedKeys = new Set(
RESTORE_PREVIEW_SECTIONS.flatMap((section) => section.keys),
);
const previewRows = RESTORE_PREVIEW_SECTIONS.map((section) => ({
labelKey: section.labelKey,
count: section.keys.reduce(
(sum, key) => sum + (numericSummary[key] ?? 0),
0,
),
})).filter((entry) => entry.count > 0);
const otherCount = Object.entries(numericSummary)
.filter(([key]) => !namedKeys.has(key))
.reduce((sum, [, value]) => sum + value, 0);
return (
<AlertDialog
open={open}
@@ -121,6 +194,57 @@ function RestoreRowDialog({
})}
</AlertDialogDescription>
</AlertDialogHeader>
{/* v1.37.20 — what the file contains, before the typed gate. */}
<div
className="rounded-md border p-3 text-sm"
data-slot="restore-preview"
>
<p className="text-muted-foreground mb-1.5 font-medium">
{t("admin.section.backups.previewTitle")}
</p>
{preview.isLoading && (
<p className="text-muted-foreground">
{t("admin.section.backups.previewLoading")}
</p>
)}
{preview.isError && (
<p className="text-muted-foreground">
{t("admin.section.backups.previewUnavailable")}
</p>
)}
{preview.isSuccess && (
<ul className="grid list-none grid-cols-1 gap-x-4 gap-y-0.5 p-0 sm:grid-cols-2">
{previewRows.map((entry) => (
<li
key={entry.labelKey}
className="flex items-baseline justify-between gap-2"
>
<span className="text-muted-foreground">
{t(`admin.section.backups.${entry.labelKey}`)}
</span>
<span className="tabular-nums">
{fmt.integer(entry.count)}
</span>
</li>
))}
{otherCount > 0 && (
<li className="flex items-baseline justify-between gap-2">
<span className="text-muted-foreground">
{t("admin.section.backups.previewOther")}
</span>
<span className="tabular-nums">
{fmt.integer(otherCount)}
</span>
</li>
)}
{previewRows.length === 0 && otherCount === 0 && (
<li className="text-muted-foreground">
{t("admin.section.backups.previewEmpty")}
</li>
)}
</ul>
)}
</div>
<div className="space-y-2">
<Label htmlFor={`restore-prompt-${row.id}`}>
{t("admin.section.backups.restorePromptLabel")}
+4 -4
View File
@@ -1732,7 +1732,7 @@ export function HealthChart({
<h2 className="text-sm font-semibold">{title}</h2>
)}
{activeBucket !== "day" && (
<span className="bg-muted/40 text-muted-foreground hidden rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex">
<span className="bg-muted/40 text-muted-foreground text-2xs hidden rounded-md px-1.5 py-0.5 font-medium tracking-wide uppercase sm:inline-flex">
{t(
activeBucket === "week"
? "charts.bucketWeekly"
@@ -1749,7 +1749,7 @@ export function HealthChart({
v1.4.19 A2 — hidden on mobile to free up the title row. */}
{effectiveCompareBaseline !== "none" && hasComparisonData && (
<span
className="text-dose-accent bg-dose-accent/10 hidden rounded-md border border-current/30 px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex"
className="text-dose-accent bg-dose-accent/10 text-2xs hidden rounded-md border border-current/30 px-1.5 py-0.5 font-medium tracking-wide uppercase sm:inline-flex"
data-slot="chart-compare-caption"
>
{t(
@@ -1761,7 +1761,7 @@ export function HealthChart({
)}
{effectiveCompareBaseline !== "none" && !hasComparisonData && (
<span
className="text-muted-foreground bg-muted/40 hidden rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide sm:inline-flex"
className="text-muted-foreground bg-muted/40 text-2xs hidden rounded-md px-1.5 py-0.5 font-medium tracking-wide sm:inline-flex"
data-slot="chart-compare-unavailable"
>
{t(
@@ -1825,7 +1825,7 @@ export function HealthChart({
)}
{mini && (
<div className="text-muted-foreground mb-1 text-[10px] font-medium tracking-wider uppercase">
<div className="text-muted-foreground text-2xs mb-1 font-medium tracking-wider uppercase">
{title}
</div>
)}
@@ -416,7 +416,7 @@ export function MedicationComplianceChart({
{showTrendChip && trend && TrendIcon ? (
<span
className={cn(
"bg-muted/40 inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase",
"bg-muted/40 text-2xs inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-medium tracking-wide uppercase",
trendColor,
)}
data-slot="medication-trend-chip"
+3 -3
View File
@@ -1018,7 +1018,7 @@ export function MoodChart({
data-slot="chart-mini"
className="bg-card border-border rounded-md border p-2"
>
<div className="text-muted-foreground mb-1 text-[10px] font-medium tracking-wider uppercase">
<div className="text-muted-foreground text-2xs mb-1 font-medium tracking-wider uppercase">
{displayTitle}
</div>
{chartBody}
@@ -1049,7 +1049,7 @@ export function MoodChart({
v1.4.19 A2 — hidden on mobile to free up the title row. */}
{effectiveCompareBaseline !== "none" && hasComparisonData && (
<span
className="text-dose-accent bg-dose-accent/10 hidden rounded-md border border-current/30 px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex"
className="text-dose-accent bg-dose-accent/10 text-2xs hidden rounded-md border border-current/30 px-1.5 py-0.5 font-medium tracking-wide uppercase sm:inline-flex"
data-slot="chart-compare-caption"
>
{t(
@@ -1061,7 +1061,7 @@ export function MoodChart({
)}
{effectiveCompareBaseline !== "none" && !hasComparisonData && (
<span
className="text-muted-foreground bg-muted/40 hidden rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide sm:inline-flex"
className="text-muted-foreground bg-muted/40 text-2xs hidden rounded-md px-1.5 py-0.5 font-medium tracking-wide sm:inline-flex"
data-slot="chart-compare-unavailable"
>
{t(
@@ -21,7 +21,7 @@ import {
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { useTableSort } from "@/hooks/use-table-sort";
import { ApiError, apiDelete, apiPatch } from "@/lib/api/api-fetch";
import { ApiError, apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { formatDateShort } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -48,8 +48,8 @@ function parseDecimal(raw: string): number | null {
/**
* v1.25.5 — reverse-chronological value history for one custom metric, with
* full edit + delete. An edit overwrites in place; delete hard-deletes (the
* value store has no soft-delete tier).
* full edit + delete. An edit overwrites in place; delete tombstones with an
* Undo toast (v1.37.20, A3-11 — parity with every other entry surface).
*/
export function CustomMetricHistoryList({
customMetricId,
@@ -87,12 +87,31 @@ export function CustomMetricHistoryList({
});
}
// v1.37.20 (A3-11) — undo parity with every peer entry surface: the
// delete tombstones server-side and the toast offers the restore.
async function restoreEntry(id: string) {
try {
await apiPost(`/api/custom-metrics/${customMetricId}/entries/restore`, {
ids: [id],
});
invalidate();
toast.success(t("customMetrics.entry.restoredToast"));
} catch {
toast.error(t("customMetrics.entry.restoreError"));
}
}
const deleteMutation = useMutation({
mutationFn: (id: string) =>
apiDelete(`/api/custom-metrics/${customMetricId}/entries/${id}`),
onSuccess: () => {
onSuccess: (_data, id) => {
invalidate();
toast.success(t("customMetrics.entry.deletedToast"));
toast.success(t("customMetrics.entry.deletedToast"), {
action: {
label: t("common.undo"),
onClick: () => void restoreEntry(id),
},
});
},
onError: () => toast.error(t("customMetrics.entry.deleteError")),
});
@@ -321,4 +321,67 @@ describe("<TodayHero>", () => {
expect(html).toContain('style="width:168px;height:168px"');
expect(html).not.toContain('data-layout="compact-all-clear"');
});
// The hero primary-content preference (`hero` on the dashboard layout
// blob): "reminders" promotes the worth-a-look rail into the hero slot;
// the score composition stays the default.
it("promotes the rail into the hero slot when the preference is reminders", () => {
const html = render(
<TodayHero digest={digest()} primaryContent="reminders" />,
);
expect(html).toContain('data-slot="today-hero"');
expect(html).toContain('data-layout="reminders"');
expect(html).toContain('data-slot="today-hero-rail"');
expect(html).toContain('data-kind="dose_window"');
expect(html).toContain('data-kind="sync_issue"');
// The score composition yields the slot entirely.
expect(html).not.toContain('data-slot="today-hero-score"');
expect(html).not.toContain('data-slot="today-hero-lead"');
expect(html).not.toContain('data-slot="today-hero-signal"');
});
it("keeps the score composition when the preference is the default score", () => {
const html = render(<TodayHero digest={digest()} primaryContent="score" />);
expect(html).toContain('data-slot="today-hero-score"');
expect(html).toContain('data-slot="today-hero-rail"');
expect(html).not.toContain('data-layout="reminders"');
});
it("shows the calm all-clear line when reminders mode has nothing to surface", () => {
const html = render(
<TodayHero
digest={digest({ worthALook: [] })}
primaryContent="reminders"
/>,
);
expect(html).toContain('data-layout="reminders"');
expect(html).toContain('data-slot="today-hero-all-clear"');
expect(html).not.toContain('data-slot="today-hero-rail"');
expect(html).not.toContain('data-slot="today-hero-score"');
});
it("keeps the honest sleep-pending note in reminders mode", () => {
const html = render(
<TodayHero
digest={digest({ phase: "provisional", sleepPending: true })}
primaryContent="reminders"
/>,
);
expect(html).toContain('data-slot="today-hero-sleep-pending"');
});
it("still degrades to nothing on a genuinely empty account in reminders mode", () => {
const html = render(
<TodayHero
digest={digest({
score: null,
topSignal: null,
briefingLead: null,
worthALook: [],
})}
primaryContent="reminders"
/>,
);
expect(html).toBe("");
});
});
+100 -63
View File
@@ -33,6 +33,14 @@
* sync.reconnect → /settings/integrations, checkup.view → /checkups), so
* `PriorityCard` wires each tap as a `<Link>` to its existing destination
* by construction; S2 invents no new backend action.
*
* Configurable primary content: the `hero` field on the dashboard layout
* blob (server-persisted, Settings → Dashboard) chooses what this card
* leads with. `"score"` keeps the composition above verbatim;
* `"reminders"` promotes the worth-a-look rail into the hero slot — the
* rail (or the calm all-clear line) becomes the card's content and the
* score composition yields entirely. The empty-account degrade and the
* freshness note are shared by both modes.
*/
import Link from "next/link";
import { Moon } from "lucide-react";
@@ -47,6 +55,7 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { DailyDigest } from "@/lib/daily/digest";
import type { HeroPrimaryContent } from "@/lib/dashboard-layout";
import {
COACH_CHECKIN_KEEP_INTENT,
COACH_CHECKIN_LETGO_INTENT,
@@ -88,9 +97,12 @@ function withoutRepeatedScore(
export function TodayHero({
digest,
renderFilteredAllClear = false,
primaryContent = "score",
}: {
digest: DailyDigest;
renderFilteredAllClear?: boolean;
/** Server-persisted hero choice from the dashboard layout blob. */
primaryContent?: HeroPrimaryContent;
}) {
const { t } = useTranslations();
// The hero is deliberately NOT mount-gated (v1.30.9: it is the LCP element
@@ -182,20 +194,88 @@ export function TodayHero({
return null;
}
// Shared fragments — identical markup in every composition, so the
// "score" and "reminders" modes cannot drift apart on either surface.
const sleepPendingNote = digest.sleepPending ? (
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<p
data-slot="today-hero-sleep-pending"
className="flex items-center gap-1.5"
>
<Moon className="size-3.5 shrink-0" aria-hidden="true" />
{t("daily.today.sleepPending")}
</p>
</div>
) : null;
/* Worth-a-look rail — S1's `PriorityCard`s, bounded 0–3. */
const rail = hasItems ? (
<div className="space-y-2" data-slot="today-hero-rail">
<h2 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("daily.today.worthALook")}
</h2>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{digest.worthALook.map((item, i) => (
<PriorityCard
key={`${item.kind}-${i}`}
item={item}
onAction={canManage ? handleAction : undefined}
onDismiss={
canManage ? (itemKey) => dismissItem.mutate(itemKey) : undefined
}
actionsPending={
item.kind === "coach_checkin" &&
(keep.isPending || letGo.isPending)
}
/>
))}
</div>
</div>
) : null;
const heroShellClassName = cn(
// The tile strip's surface plus the ONE sanctioned Today atmosphere:
// `.today-hero-wash` leans a faint `--primary` mix over the theme
// `--card` toward the ring corner (the `.wellness-tile` color-mix
// pattern, softer than the insights hero) so the promoted day's read
// carries a quiet identity without a banner gradient or glow.
"bg-card today-hero-wash border-border relative isolate overflow-hidden rounded-xl border",
"p-4 md:p-6",
);
// Reminders-first composition — the rail IS the hero. The score
// composition yields the slot entirely (the ring keeps its home on
// /insights); an empty rail degrades to the same calm all-clear line
// the score mode uses, never an empty card.
if (primaryContent === "reminders") {
return (
<section
data-slot="today-hero"
data-phase={digest.phase}
data-layout="reminders"
className={heroShellClassName}
>
<div className="flex flex-col gap-3 md:gap-4">
{rail ?? (
<p
data-slot="today-hero-all-clear"
className="text-muted-foreground text-sm"
>
{t("daily.today.allClear")}
</p>
)}
{sleepPendingNote}
</div>
</section>
);
}
return (
<section
data-slot="today-hero"
data-phase={digest.phase}
data-layout={compactAllClear ? "compact-all-clear" : "narrative"}
className={cn(
// The tile strip's surface plus the ONE sanctioned Today atmosphere:
// `.today-hero-wash` leans a faint `--primary` mix over the theme
// `--card` toward the ring corner (the `.wellness-tile` color-mix
// pattern, softer than the insights hero) so the promoted day's read
// carries a quiet identity without a banner gradient or glow.
"bg-card today-hero-wash border-border relative isolate overflow-hidden rounded-xl border",
"p-4 md:p-6",
)}
className={heroShellClassName}
>
{/* v1.29.1 — tightened after the v1.29.0 ring cluster was removed: the
hero read as half-empty (short lead → gap → rail). Compact section
@@ -252,17 +332,7 @@ export function TodayHero({
{/* In the compact fallback the quiet freshness tier belongs next
to the smaller ring. That uses the otherwise-empty leading
space and avoids adding a second row below the dial. */}
{compactAllClear && digest.sleepPending ? (
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<p
data-slot="today-hero-sleep-pending"
className="flex items-center gap-1.5"
>
<Moon className="size-3.5 shrink-0" aria-hidden="true" />
{t("daily.today.sleepPending")}
</p>
</div>
) : null}
{compactAllClear ? sleepPendingNote : null}
</div>
{/* Health score ring — `flat` (no sweep/bloom), server-computed
@@ -309,53 +379,20 @@ export function TodayHero({
opacity modifier). Freshness note (plan §2.4) — provisional day,
last night's sleep not yet folded in. Muted, non-blocking,
refreshes in place when the morning job lands. */}
{!compactAllClear && digest.sleepPending ? (
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<p
data-slot="today-hero-sleep-pending"
className="flex items-center gap-1.5"
>
<Moon className="size-3.5 shrink-0" aria-hidden="true" />
{t("daily.today.sleepPending")}
</p>
</div>
) : null}
{!compactAllClear ? sleepPendingNote : null}
{/* Worth-a-look rail — S1's `PriorityCard`s, bounded 0–3. When the
digest is all-clear, a first-class muted line stands in for the
rail (calm inversion of an alarm), never an empty card. */}
{hasItems ? (
<div className="space-y-2" data-slot="today-hero-rail">
<h2 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("daily.today.worthALook")}
</h2>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{digest.worthALook.map((item, i) => (
<PriorityCard
key={`${item.kind}-${i}`}
item={item}
onAction={canManage ? handleAction : undefined}
onDismiss={
canManage
? (itemKey) => dismissItem.mutate(itemKey)
: undefined
}
actionsPending={
item.kind === "coach_checkin" &&
(keep.isPending || letGo.isPending)
}
/>
))}
</div>
</div>
) : !compactAllClear ? (
<p
data-slot="today-hero-all-clear"
className="text-muted-foreground text-sm"
>
{t("daily.today.allClear")}
</p>
) : null}
{rail ??
(!compactAllClear ? (
<p
data-slot="today-hero-all-clear"
className="text-muted-foreground text-sm"
>
{t("daily.today.allClear")}
</p>
) : null)}
</div>
</section>
);
+8 -18
View File
@@ -40,6 +40,7 @@ import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { useUrlFilterSync } from "@/hooks/use-url-filter-sync";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { encounterKindText } from "@/components/encounters/encounter-labels";
@@ -51,7 +52,6 @@ import {
useCoachLaunch,
} from "@/lib/insights/coach-launch-context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import type { DocumentVaultFilters } from "@/lib/query-keys/documents";
import {
DOCUMENT_BULK_MAX_IDS,
type DocumentBulkAction,
@@ -263,23 +263,13 @@ export function DocumentsView() {
}, [authLoading, isAuthenticated, moduleEnabled, router]);
// ── URL-owned filter state ────────────────────────────────────────────
const filters = useMemo(
() => parseVaultSearchParams(new URLSearchParams(searchParams.toString())),
[searchParams],
);
const applyFilters = useCallback(
(next: DocumentVaultFilters, mode: "push" | "replace") => {
const search = vaultFiltersToSearch(next);
const href = search ? `${pathname}?${search}` : pathname;
if (mode === "push") {
router.push(href, { scroll: false });
} else {
router.replace(href, { scroll: false });
}
},
[pathname, router],
);
// The parse/serialise round trip stays in `vault-utils.ts`; the router
// plumbing lives in the shared hook (which the measurements and mood
// lists reuse — this surface is the pattern's origin).
const { filters, applyFilters } = useUrlFilterSync({
parse: parseVaultSearchParams,
serialise: vaultFiltersToSearch,
});
// Search draft debounces into the URL (200 ms); an external URL change
// (back button, deep link) re-seeds the draft — render-phase derived-state
@@ -6,6 +6,7 @@ import {
SleepStageStackedBar,
STAGE_ORDER,
buildCompositionRows,
compositionTotals,
windowHasNap,
type SleepStageBreakdown,
type SleepStageNight,
@@ -270,6 +271,46 @@ describe("nap band", () => {
expect(windowHasNap(rows)).toBe(false);
});
/**
* The tooltip footer used to label a single night's total "Average per
* night" — the orphaned title of a headline card dropped in v1.22.0. The
* footer now says what the figure is: the main sleep session, with a
* second "total" line on days a nap rides on top (issue #611's
* Main sleep / Naps / Total sleep shape). Recharts renders no tooltip
* under SSR, so the split is pinned through the pure totals helper the
* tooltip reads.
*/
it("totals the main session and the whole day separately when a nap rides on the column", () => {
const perNight: SleepStageNight[] = [
{
dayKey: "2026-06-04",
stages: { CORE: 250, DEEP: 50, AWAKE: 20 },
napMinutes: 80,
napCount: 1,
},
];
const { rows } = buildCompositionRows(perNight, 7, label);
const totals = compositionTotals(rows[0]);
// The main figure is the night column alone — the nap never leaks in,
// so the per-stage percentages keep meaning "of this night".
expect(totals.nightMinutes).toBe(320);
expect(totals.napMinutes).toBe(80);
// The day line is the whole drawn column: night plus nap.
expect(totals.dayMinutes).toBe(400);
});
it("collapses the totals to the night alone on a day without a nap", () => {
const perNight: SleepStageNight[] = [
{ dayKey: "2026-06-03", stages: { CORE: 250, DEEP: 50 } },
];
const { rows } = buildCompositionRows(perNight, 7, label);
const totals = compositionTotals(rows[0]);
expect(totals.nightMinutes).toBe(300);
expect(totals.napMinutes).toBe(0);
// No second line to render — the day IS the night.
expect(totals.dayMinutes).toBe(300);
});
it("drops the band when the nap falls outside the visible window", () => {
// The nap is on the oldest day; a 2-day window scrolls past it, so the
// legend must not keep a nap entry the user cannot see.
@@ -29,7 +29,7 @@ export function MessageTokenFooter({ tokens, model }: MessageTokenFooterProps) {
return (
<p
data-slot="coach-token-footer"
className="text-muted-foreground text-[11px] tabular-nums"
className="text-muted-foreground text-xs tabular-nums"
>
{label}
</p>
@@ -174,7 +174,7 @@ export function CoverageMeter({
data-slot="coverage-meter-label"
className={cn(
"text-muted-foreground tabular-nums",
size === "md" ? "text-xs" : "text-[11px]",
size === "md" ? "text-xs" : "text-2xs",
)}
>
{axisLabel ??
@@ -231,7 +231,7 @@ function ContributorRow({ contributor }: { contributor: AnatomyContributor }) {
</span>
<span
data-slot="anatomy-contributor-value"
className="text-muted-foreground text-[11px] tabular-nums"
className="text-muted-foreground text-xs tabular-nums"
>
{present
? t("insights.derived.anatomy.contributorValue", {
@@ -116,7 +116,7 @@ function MoodCorrelationCard({
// below the title on <sm (see the sm:hidden twin under the
// header); only the compact badge stays pinned right.
<div className="flex min-w-0 items-center gap-1.5">
<Badge variant="outline" className="text-[10px]">
<Badge variant="outline" className="text-2xs">
{t(STRENGTH_KEY[data.result.strength])}
</Badge>
{/* v1.12.4 (C4) — the "{n} paired days · r" line sat under the
@@ -231,6 +231,33 @@ export function windowHasNap(rows: readonly CompositionRow[]): boolean {
);
}
/**
* Totals for one hovered column, split the way the tooltip reports them.
*
* `nightMinutes` is the sum of the stacked stage bands — the main session
* only, the same figure the column draws and the per-stage percentages
* divide by. `napMinutes` is the day's inferred naps. `dayMinutes` is the
* whole drawn column, night plus naps; it equals `nightMinutes` on a day
* without one, and the tooltip renders its line only when it differs.
*
* Pulled out of the tooltip body because Recharts renders no tooltip under
* SSR — this is the only place the footer split can be pinned by a test.
*/
export function compositionTotals(row: CompositionRow | undefined): {
nightMinutes: number;
napMinutes: number;
dayMinutes: number;
} {
let nightMinutes = 0;
for (const stage of STAGE_ORDER) {
const minutes = row?.[stage];
if (typeof minutes === "number") nightMinutes += minutes;
}
const nap = row?.[NAP_KEY];
const napMinutes = typeof nap === "number" ? nap : 0;
return { nightMinutes, napMinutes, dayMinutes: nightMinutes + napMinutes };
}
export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
const { t, locale } = useTranslations();
@@ -379,17 +406,13 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
Record<string, number | string> | undefined;
const napCount =
typeof row?.napCount === "number" ? row.napCount : 0;
// The footer total is the NIGHT. The nap is listed above
// it as its own line and stays out of the sum, so the
// per-stage percentages keep meaning "of this night".
const totalNight = payload.reduce(
(sum, entry) =>
entry.dataKey === NAP_KEY
? sum
: sum +
(typeof entry.value === "number" ? entry.value : 0),
0,
);
// The footer's first line is the NIGHT. The nap is listed
// above it as its own line and stays out of that sum, so
// the per-stage percentages keep meaning "of this night".
// On a day with a nap a second line carries the whole
// column — main sleep plus naps — the Main sleep / Naps /
// Total split issue #611 asked for.
const totals = compositionTotals(row);
return (
<div className="bg-popover text-popover-foreground rounded-md border p-2 text-xs shadow-md">
<div className="border-border mb-1 border-b pb-1 font-medium">
@@ -409,8 +432,10 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
: stageLabels[NAP_KEY]
: (stageLabels[stage] ?? stage);
const pct =
totalNight > 0
? Math.round((minutes / totalNight) * 100)
totals.nightMinutes > 0
? Math.round(
(minutes / totals.nightMinutes) * 100,
)
: 0;
return (
<div
@@ -435,10 +460,22 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
</div>
);
})}
{totalNight > 0 && (
<div className="border-border mt-1 flex items-center justify-between gap-3 border-t pt-1 font-medium">
<span>{t("insights.sleep.headlineTitle")}</span>
<span>{formatDurationMinutes(totalNight, t)}</span>
{totals.nightMinutes > 0 && (
<div className="border-border mt-1 border-t pt-1 font-medium">
<div className="flex items-center justify-between gap-3">
<span>{t("insights.sleep.mainSleep")}</span>
<span>
{formatDurationMinutes(totals.nightMinutes, t)}
</span>
</div>
{totals.napMinutes > 0 && (
<div className="flex items-center justify-between gap-3">
<span>{t("insights.sleep.totalSleep")}</span>
<span>
{formatDurationMinutes(totals.dayMinutes, t)}
</span>
</div>
)}
</div>
)}
</div>
@@ -28,6 +28,8 @@ vi.mock("@/hooks/use-measurement-reminders", () => ({
update: { mutate: vi.fn(), isPending: false },
remove: { mutate: vi.fn(), isPending: false },
satisfy: { mutate: vi.fn(), isPending: false },
skip: { mutate: vi.fn(), isPending: false },
snooze: { mutate: vi.fn(), isPending: false },
}),
}));
@@ -231,7 +231,8 @@ export function VorsorgeSection({
isError,
refetch,
} = useMeasurementReminders(enabled);
const { create, update, remove, satisfy } = useMeasurementReminderMutations();
const { create, update, remove, satisfy, skip, snooze } =
useMeasurementReminderMutations();
// v1.18.6 (MOD-03) — page view (cards/list) + manual order persist
// client-side; the settings page writes them, the page reads them.
const { prefs } = useModuleListPrefs("vorsorge");
@@ -743,8 +744,16 @@ export function VorsorgeSection({
onFileVisit={() => openVisitFor(reminder)}
onRemove={() => remove.mutate(reminder.id)}
onEdit={() => openEdit(reminder)}
onSkip={() => skip.mutate(reminder.id)}
onSnooze={(until) =>
snooze.mutate({ id: reminder.id, until })
}
busy={
satisfy.isPending || remove.isPending || update.isPending
satisfy.isPending ||
remove.isPending ||
update.isPending ||
skip.isPending ||
snooze.isPending
}
/>
</li>
@@ -776,6 +785,18 @@ export function VorsorgeSection({
* means due/overdue now, lower means time still remains in the window.
* Returns null when there is no interval to measure against (RRULE / unset).
*/
/** v1.37.20 (#223) — the postpone sheet's quick-chip offsets, in days. */
const SNOOZE_CHIP_DAYS = [7, 30, 90] as const;
/**
* The local calendar day (ISO `yyyy-MM-dd`) an instant falls on in `tz`.
* Snooze is day-precision by contract: the server resolves the day to the
* reminder's notifyHour in the profile timezone.
*/
function isoDayInTz(atMs: number, tz: string): string {
return new Date(atMs).toLocaleDateString("sv-SE", { timeZone: tz });
}
function intervalProgress(
reminder: MeasurementReminder,
now: number,
@@ -797,6 +818,8 @@ function VorsorgeCard({
onFileVisit,
onRemove,
onEdit,
onSkip,
onSnooze,
busy,
}: {
reminder: MeasurementReminder;
@@ -807,6 +830,10 @@ function VorsorgeCard({
onFileVisit: () => void;
onRemove: () => void;
onEdit: () => void;
/** v1.37.20 (#223) — honest skip; the server restarts the interval. */
onSkip: () => void;
/** v1.37.20 (#223) — snooze to a calendar day (ISO YYYY-MM-DD). */
onSnooze: (until: string) => void;
busy: boolean;
}) {
const { t } = useTranslations();
@@ -819,6 +846,11 @@ function VorsorgeCard({
// zone from their profile.
const displayTz = useDisplayTimezone();
const [confirmDelete, setConfirmDelete] = useState(false);
// v1.37.20 (#223) — the postpone sheet (snooze quick-chips + date field,
// with the honest-skip zone below) and the skip confirm inside it.
const [postponeOpen, setPostponeOpen] = useState(false);
const [snoozeDate, setSnoozeDate] = useState("");
const [confirmSkip, setConfirmSkip] = useState(false);
const due = relativeDueKey(reminder.nextDueAt, now, displayTz);
const cadence =
reminder.intervalDays != null
@@ -897,6 +929,16 @@ function VorsorgeCard({
<Pencil className="mr-2 h-4 w-4" />
{t("measurementReminders.edit")}
</DropdownMenuItem>
{/* v1.37.20 (#223) — postpone lives in the kebab, never in the
primary-action bar (v1.27.5 rule: the action button keeps one
constant look in every state). */}
<DropdownMenuItem
disabled={busy}
onSelect={() => setPostponeOpen(true)}
>
<CalendarClock className="mr-2 h-4 w-4" />
{t("measurementReminders.postpone.menuItem")}
</DropdownMenuItem>
{/* v1.18.7 (Wave E) — jump to the measurements list pre-filtered to
this reminder's type. Only for measurement-linked reminders; a
free-text reminder has no readings to show. */}
@@ -1007,6 +1049,162 @@ function VorsorgeCard({
</AlertDialog>
);
// v1.37.20 (#223) — resolved skip/snooze state, clock comparisons only
// (the DTO comment states the contract: the server resolves, the client
// compares). Meta lines are muted text — never a card colour change.
const isSnoozed =
reminder.snoozedUntil != null &&
new Date(reminder.snoozedUntil).getTime() > now;
const isSkippedCycle =
reminder.lastSkippedAt != null &&
(reminder.lastSatisfiedAt == null ||
new Date(reminder.lastSkippedAt).getTime() >
new Date(reminder.lastSatisfiedAt).getTime());
const closePostpone = () => {
setPostponeOpen(false);
setSnoozeDate("");
setConfirmSkip(false);
};
const minSnoozeDate = isoDayInTz(now + DAY_MS, displayTz);
const postponeSheet = (
<ResponsiveSheet
open={postponeOpen}
onOpenChange={(open) => {
if (!open) closePostpone();
}}
title={t("measurementReminders.postpone.title")}
description={t("measurementReminders.postpone.description")}
footer={
<>
<Button type="button" variant="outline" onClick={closePostpone}>
{t("common.cancel")}
</Button>
<Button
type="button"
disabled={!snoozeDate || busy}
onClick={() => {
onSnooze(snoozeDate);
closePostpone();
}}
>
{t("measurementReminders.postpone.confirm")}
</Button>
</>
}
>
<div className="space-y-4">
{/* Quick chips write the date field rather than firing directly, so
the person always sees (and can adjust) what will be sent. */}
<div className="flex flex-wrap gap-2">
{SNOOZE_CHIP_DAYS.map((days) => {
const chipDate = isoDayInTz(now + days * DAY_MS, displayTz);
return (
<Button
key={days}
type="button"
size="sm"
variant={snoozeDate === chipDate ? "secondary" : "outline"}
onClick={() => setSnoozeDate(chipDate)}
>
{t("measurementReminders.postpone.chip", { days })}
</Button>
);
})}
</div>
<div className="space-y-2">
<Label htmlFor={`snooze-date-${reminder.id}`}>
{t("measurementReminders.postpone.dateLabel")}
</Label>
<DateField
id={`snooze-date-${reminder.id}`}
value={snoozeDate}
onChange={setSnoozeDate}
min={minSnoozeDate}
/>
</div>
{/* The honest-skip zone sits set apart below the snooze controls:
a different decision (let this cycle go, restart the rhythm)
with its own confirm step. */}
<div className="space-y-2 border-t pt-4">
<p className="text-sm font-medium">
{t("measurementReminders.skip.heading")}
</p>
<p className="text-muted-foreground text-sm">
{t("measurementReminders.skip.explainer")}
</p>
<Button
type="button"
variant="outline"
disabled={busy}
onClick={() => setConfirmSkip(true)}
data-slot="vorsorge-skip"
>
{t("measurementReminders.skip.action")}
</Button>
</div>
</div>
</ResponsiveSheet>
);
const skipConfirmDialog = (
<AlertDialog open={confirmSkip} onOpenChange={setConfirmSkip}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("measurementReminders.skip.confirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("measurementReminders.skip.explainer")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
onSkip();
closePostpone();
}}
>
{t("measurementReminders.skip.action")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
// Muted meta lines shared by both branches: "pushed back until {date}" /
// "skipped on {date}" (+ lifetime count once it says something). "Last
// done" stays lastSatisfiedAt — a skip never reads as done.
const skipSnoozeMeta =
isSnoozed || isSkippedCycle ? (
<div
className="text-muted-foreground space-y-0.5 text-xs"
data-slot="vorsorge-skip-snooze-meta"
>
{isSnoozed && reminder.snoozedUntil && (
<p>
{t("measurementReminders.snoozedUntil", {
date: fmt.date(new Date(reminder.snoozedUntil)),
})}
</p>
)}
{isSkippedCycle && reminder.lastSkippedAt && (
<p>
{t("measurementReminders.skippedOn", {
date: fmt.date(new Date(reminder.lastSkippedAt)),
})}
{reminder.skipCount > 1
? ` · ${t("measurementReminders.skipCount", {
count: reminder.skipCount,
})}`
: null}
</p>
)}
</div>
) : null;
// v1.18.6 (MOD-03) — compact list row variant.
if (view === "list") {
return (
@@ -1030,6 +1228,7 @@ function VorsorgeCard({
<span>{categoryLabel}</span>
{cadenceText ? <span>{cadenceText}</span> : null}
</div>
{skipSnoozeMeta}
</div>
<div className="flex shrink-0 items-center gap-1">
{/* The same gate the cards branch applies through
@@ -1078,6 +1277,8 @@ function VorsorgeCard({
</CardContent>
</Card>
{deleteDialog}
{postponeSheet}
{skipConfirmDialog}
</>
);
}
@@ -1156,6 +1357,7 @@ function VorsorgeCard({
/>
<CardContent className="flex h-full flex-col space-y-3.5">
{nextLastSlot}
{skipSnoozeMeta}
{/* v1.18.6 (MOD-06) — light gamification: progress through the
current interval toward next-due, reusing the medication card's
@@ -1208,6 +1410,8 @@ function VorsorgeCard({
</Card>
{deleteDialog}
{postponeSheet}
{skipConfirmDialog}
</>
);
}
@@ -27,6 +27,7 @@ const baseMeasurements = [
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/measurements",
useSearchParams: () => new URLSearchParams(""),
}));
vi.mock("@tanstack/react-query", () => ({
@@ -40,6 +40,7 @@ const baseMeasurements = [
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/measurements",
useSearchParams: () => new URLSearchParams(""),
}));
vi.mock("@tanstack/react-query", () => ({
@@ -20,6 +20,7 @@ import { renderToStaticMarkup } from "react-dom/server";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/measurements",
useSearchParams: () => new URLSearchParams(""),
}));
vi.mock("@tanstack/react-query", () => ({
@@ -35,6 +35,7 @@ const baseMeasurements = [
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/measurements",
useSearchParams: () => new URLSearchParams(""),
}));
vi.mock("@tanstack/react-query", () => ({
@@ -0,0 +1,81 @@
/**
* URL round trip for the measurements list filters (`?type&source&from&to&
* min&max`) — the vault-utils contract applied to this surface: parsing is
* lenient (a hand-edited URL drops the invalid facet, never breaks the
* page), serialisation omits defaults, and parse(serialise(x)) is identity
* so deep links stay stable.
*/
import { describe, expect, it } from "vitest";
import {
measurementListFiltersToSearch,
numericInputOrUndefined,
parseMeasurementListSearchParams,
type MeasurementListFilters,
} from "../measurement-list-filters";
describe("parseMeasurementListSearchParams", () => {
it.each<[string, MeasurementListFilters]>([
["", {}],
["type=WEIGHT", { type: "WEIGHT" }],
["type=WEIGHT&source=WITHINGS", { type: "WEIGHT", source: "WITHINGS" }],
[
"from=2026-05-01&to=2026-05-31",
{ fromDay: "2026-05-01", toDay: "2026-05-31" },
],
["min=60&max=90.5", { valueMin: "60", valueMax: "90.5" }],
[
"type=PULSE&source=APPLE_HEALTH&from=2026-01-01&min=-5",
{
type: "PULSE",
source: "APPLE_HEALTH",
fromDay: "2026-01-01",
valueMin: "-5",
},
],
])("parses %j", (search, expected) => {
expect(
parseMeasurementListSearchParams(new URLSearchParams(search)),
).toEqual(expected);
});
it("drops facets a stale or hand-edited URL cannot honour", () => {
expect(
parseMeasurementListSearchParams(
new URLSearchParams(
"type=NOT_A_TYPE&source=NOT_A_SOURCE&from=2026-1-1&to=yesterday&min=abc&max=1e999",
),
),
).toEqual({});
// 1e999 is Infinity — not finite, so the bound is dropped too.
expect(numericInputOrUndefined("1e999")).toBeUndefined();
});
});
describe("measurementListFiltersToSearch", () => {
it("serialises the empty filter set to an empty string (bare URL)", () => {
expect(measurementListFiltersToSearch({})).toBe("");
});
it.each<MeasurementListFilters>([
{},
{ type: "WEIGHT" },
{ type: "BLOOD_PRESSURE_SYS", source: "MANUAL" },
{ fromDay: "2026-05-01", toDay: "2026-05-31" },
{ valueMin: "60", valueMax: "90.5" },
{
type: "PULSE",
source: "APPLE_HEALTH",
fromDay: "2026-01-01",
toDay: "2026-12-31",
valueMin: "40",
valueMax: "180",
},
])("round-trips %j", (filters) => {
expect(
parseMeasurementListSearchParams(
new URLSearchParams(measurementListFiltersToSearch(filters)),
),
).toEqual(filters);
});
});
@@ -11,6 +11,7 @@ import { renderToStaticMarkup } from "react-dom/server";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/insights/values/WEIGHT",
useSearchParams: () => new URLSearchParams(""),
}));
vi.mock("@tanstack/react-query", () => ({
@@ -51,6 +51,7 @@ vi.mock("@tanstack/react-query", () => ({
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/measurements",
useSearchParams: () => new URLSearchParams(""),
}));
vi.mock("@/hooks/use-auth", () => ({
@@ -0,0 +1,220 @@
/**
* URL-owned filter state on the measurements management list (the
* documents-vault pattern via `useUrlFilterSync`). Two directions, both
* pinned:
*
* - a filter change writes the serialised facet set to the page URL
* (push for the discrete selects, replace for the committed date
* bounds, bare pathname when everything clears);
* - mounting with a query string restores every facet — into the filter
* rail's controls AND into the list's TanStack query key, so the
* restored view refetches exactly what the shared link showed.
*
* SSR-only per project convention: the rail primitives are stubbed with
* prop-capturing markers, and the captured callbacks stand in for the
* user's click (post-render setState is a no-op under the server renderer;
* the router spies record the navigation the click causes).
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const baseMeasurements = [
{
id: "m-1",
type: "WEIGHT",
value: 81.5,
unit: "kg",
source: "MANUAL",
measuredAt: "2026-05-09T08:30:00.000Z",
notes: null,
},
];
let mockSearch = "";
const pushMock = vi.fn();
const replaceMock = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock, replace: replaceMock }),
usePathname: () => "/measurements",
useSearchParams: () => new URLSearchParams(mockSearch),
}));
const recordedQueryKeys: unknown[] = [];
vi.mock("@tanstack/react-query", () => ({
useQuery: (options: { queryKey: unknown }) => {
recordedQueryKeys.push(options.queryKey);
return {
data: { measurements: baseMeasurements, meta: { total: 1 } },
isLoading: false,
};
},
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isError: false,
error: null,
}),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", username: "testuser", role: "USER" },
isAuthenticated: true,
isLoading: false,
}),
}));
// Prop-capturing stubs for the rail primitives (render order in the list:
// date range, type, source, value range).
interface SelectProps {
label: string;
value: string;
onValueChange: (value: string) => void;
}
interface RangeProps {
from?: string;
to?: string;
min?: string;
max?: string;
onFromChange?: (value: string) => void;
onToChange?: (value: string) => void;
onMinChange?: (value: string) => void;
onMaxChange?: (value: string) => void;
}
let selects: SelectProps[] = [];
let barProps: { isFiltered: boolean; onReset: () => void } | null = null;
let dateRange: RangeProps | null = null;
let valueRange: RangeProps | null = null;
vi.mock("@/components/ui/filter-bar", () => ({
FilterBar: (props: {
isFiltered: boolean;
onReset: () => void;
children?: unknown;
}) => {
barProps = { isFiltered: props.isFiltered, onReset: props.onReset };
return <div data-slot="filter-bar">{props.children as never}</div>;
},
FilterBarSelect: (props: SelectProps) => {
selects.push(props);
return <span data-slot="filter-bar-pill" />;
},
FilterBarDateRange: (props: RangeProps) => {
dateRange = props;
return <span data-slot="filter-bar-pill" />;
},
FilterBarNumberRange: (props: RangeProps) => {
valueRange = props;
return <span data-slot="filter-bar-pill" />;
},
}));
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { MeasurementList } from "../measurement-list";
function render(search: string) {
mockSearch = search;
selects = [];
barProps = null;
dateRange = null;
valueRange = null;
recordedQueryKeys.length = 0;
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MeasurementList />
</I18nProvider>,
);
}
const typeSelect = () => selects[0]!;
const sourceSelect = () => selects[1]!;
beforeEach(() => {
pushMock.mockClear();
replaceMock.mockClear();
});
describe("MeasurementList — filter changes write the URL", () => {
it("pushes the type facet", () => {
render("");
typeSelect().onValueChange("WEIGHT");
expect(pushMock).toHaveBeenCalledWith("/measurements?type=WEIGHT", {
scroll: false,
});
});
it("pushes the source facet on top of the active type", () => {
render("type=WEIGHT");
sourceSelect().onValueChange("WITHINGS");
expect(pushMock).toHaveBeenCalledWith(
"/measurements?type=WEIGHT&source=WITHINGS",
{ scroll: false },
);
});
it("replaces (no history entry) for a committed date bound", () => {
render("");
dateRange!.onFromChange!("2026-05-01");
expect(replaceMock).toHaveBeenCalledWith("/measurements?from=2026-05-01", {
scroll: false,
});
expect(pushMock).not.toHaveBeenCalled();
});
it("clearing the last facet restores the bare pathname", () => {
render("type=WEIGHT");
typeSelect().onValueChange("ALL");
expect(pushMock).toHaveBeenCalledWith("/measurements", { scroll: false });
});
it("reset clears every facet in one push", () => {
render("type=WEIGHT&source=WITHINGS&from=2026-05-01&min=60");
expect(barProps!.isFiltered).toBe(true);
barProps!.onReset();
expect(pushMock).toHaveBeenCalledWith("/measurements", { scroll: false });
});
});
describe("MeasurementList — mounting with a query string restores the filters", () => {
it("seeds every rail control from the URL", () => {
render(
"type=WEIGHT&source=WITHINGS&from=2026-05-01&to=2026-05-31&min=60&max=90",
);
expect(typeSelect().value).toBe("WEIGHT");
expect(sourceSelect().value).toBe("WITHINGS");
expect(dateRange!.from).toBe("2026-05-01");
expect(dateRange!.to).toBe("2026-05-31");
expect(valueRange!.min).toBe("60");
expect(valueRange!.max).toBe("90");
expect(barProps!.isFiltered).toBe(true);
});
it("keys the list query off the restored facets (TZ-pinned day bounds)", () => {
render(
"type=WEIGHT&source=WITHINGS&from=2026-05-01&to=2026-05-31&min=60&max=90",
);
// Suite TZ is pinned to UTC, so the local day bounds are deterministic.
expect(recordedQueryKeys).toContainEqual(
queryKeys.measurementsList({
type: "WEIGHT",
sourceEq: "WITHINGS",
from: "2026-05-01T00:00:00.000Z",
to: "2026-05-31T23:59:59.999Z",
valueMin: 60,
valueMax: 90,
page: 1,
sortBy: "measuredAt",
sortDir: "desc",
mode: "raw",
}),
);
});
it("drops an invalid facet instead of breaking the page", () => {
render("type=NOT_A_TYPE&source=WITHINGS");
expect(typeSelect().value).toBe("ALL");
expect(sourceSelect().value).toBe("WITHINGS");
});
});
@@ -0,0 +1,98 @@
/**
* Pure URL-facet helpers for the measurements management list, following the
* documents-vault pattern (`vault-utils.ts`): the list's filter state lives in
* the page URL (`?type&source&from&to&min&max`) so a filtered view is
* deep-linkable and survives navigation, reload and back/forward. Parsing is
* lenient — a hand-edited or stale URL drops the invalid facet rather than
* breaking the page — and serialisation omits defaults so the unfiltered list
* keeps a bare URL. Kept out of the client component so the round trip stays
* trivially unit-testable without a render harness.
*/
import { measurementSourceEnum } from "@/lib/validations/measurement";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "./measurement-list-meta";
export interface MeasurementListFilters {
/** Canonical `MeasurementType`; absent = all types. */
type?: string;
/** `MeasurementSource` enum value; absent = all sources. */
source?: string;
/** Inclusive day bounds, `YYYY-MM-DD` (the date inputs' committed values). */
fromDay?: string;
toDay?: string;
/** Validated numeric input strings for the value-range pill. */
valueMin?: string;
valueMax?: string;
}
const SOURCE_SET = new Set<string>(measurementSourceEnum.options);
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
function dayOrUndefined(value: string | null): string | undefined {
const trimmed = value?.trim();
return trimmed && DAY_PATTERN.test(trimmed) ? trimmed : undefined;
}
/**
* A value-range bound survives the URL only as a finite number the list's
* `valueMin`/`valueMax` query params would accept; anything else (including
* a mid-typing fragment someone pasted) is dropped, not 422'd.
*/
export function numericInputOrUndefined(
value: string | null | undefined,
): string | undefined {
const trimmed = value?.trim();
if (!trimmed || trimmed.length > 20) return undefined;
return Number.isFinite(Number(trimmed)) ? trimmed : undefined;
}
/**
* Parse the list's URL search params (`?type&source&from&to&min&max`) into
* the filter object. Unknown types/sources are dropped (a stale deep link
* never traps the user on a broken filter — same contract the old
* `?type=` seeding had), malformed days and non-numeric bounds likewise.
*/
export function parseMeasurementListSearchParams(
params: URLSearchParams,
): MeasurementListFilters {
const filters: MeasurementListFilters = {};
const type = params.get("type")?.trim();
if (type && type in MEASUREMENT_TYPE_LABEL_KEYS) filters.type = type;
const source = params.get("source")?.trim();
if (source && SOURCE_SET.has(source)) filters.source = source;
const fromDay = dayOrUndefined(params.get("from"));
if (fromDay) filters.fromDay = fromDay;
const toDay = dayOrUndefined(params.get("to"));
if (toDay) filters.toDay = toDay;
const valueMin = numericInputOrUndefined(params.get("min"));
if (valueMin) filters.valueMin = valueMin;
const valueMax = numericInputOrUndefined(params.get("max"));
if (valueMax) filters.valueMax = valueMax;
return filters;
}
/**
* Serialise the filter object back into the page URL's search string (no
* leading `?`; empty string for the default view). Inverse of
* `parseMeasurementListSearchParams` — round-tripping is pinned by test so
* the `?type=` deep links from the Vorsorge card and the type-badge
* drill-down stay stable.
*/
export function measurementListFiltersToSearch(
filters: MeasurementListFilters,
): string {
const sp = new URLSearchParams();
if (filters.type) sp.set("type", filters.type);
if (filters.source) sp.set("source", filters.source);
if (filters.fromDay) sp.set("from", filters.fromDay);
if (filters.toDay) sp.set("to", filters.toDay);
if (filters.valueMin) sp.set("min", filters.valueMin);
if (filters.valueMax) sp.set("max", filters.valueMax);
return sp.toString();
}
@@ -60,8 +60,9 @@ import {
ChevronDown,
MoreHorizontal,
} from "lucide-react";
import { Fragment, useCallback, useId, useState } from "react";
import { Fragment, useCallback, useEffect, useId, useState } from "react";
import { useTableSort } from "@/hooks/use-table-sort";
import { useUrlFilterSync } from "@/hooks/use-url-filter-sync";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import { formatDateOrRelative, formatDateTime } from "@/lib/format";
@@ -97,6 +98,11 @@ import {
MEASUREMENT_TYPE_ICONS as TYPE_ICONS,
MEASUREMENT_TYPE_COLORS as TYPE_COLORS,
} from "./measurement-list-meta";
import {
measurementListFiltersToSearch,
numericInputOrUndefined,
parseMeasurementListSearchParams,
} from "./measurement-list-filters";
import {
ApiError,
apiDelete,
@@ -190,13 +196,6 @@ interface MeasurementListProps {
* scoped to this type; the count caption still shows.
*/
lockedType?: string;
/**
* v1.18.7 (Wave E) — seed the (still user-editable) type filter from a
* `?type=<MEASUREMENT_TYPE>` deep link, e.g. the Vorsorge card's "Show
* measurements" action. Unlike `lockedType` the full filter rail stays
* visible so the user can widen or change the filter afterwards.
*/
initialType?: string;
}
const PAGE_SIZE = 25;
@@ -281,7 +280,6 @@ export function MeasurementList({
onEdit,
onAddFirst,
lockedType,
initialType,
}: MeasurementListProps) {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -342,22 +340,45 @@ export function MeasurementList({
: `/measurements?type=${encodeURIComponent(type)}`,
);
};
// v1.8.5 — when `lockedType` is set the list is pinned to that metric
// and the type selector is hidden; the filter state seeds from it.
// v1.18.7 (Wave E) — `initialType` (from a `?type=` deep link) seeds the
// same filter but keeps the rail interactive so the user can change it.
const [typeFilter, setTypeFilterRaw] = useState<string>(
lockedType ?? initialType ?? "ALL",
);
// URL-owned filter state (the documents-vault pattern): type, source, date
// range and value range parse from `?type&source&from&to&min&max` and every
// change writes back through the router, so a filtered view survives
// navigation, reload and back/forward. The `?type=` deep links (Vorsorge
// card, type-badge drill-down) now simply ARE the filter state.
// v1.8.5 — when `lockedType` is set the list is pinned to that metric, the
// type selector is hidden, and the URL belongs to the insights subpage —
// the remaining filters stay in component memory (`syncToUrl: false`).
const { filters, applyFilters } = useUrlFilterSync({
parse: parseMeasurementListSearchParams,
serialise: measurementListFiltersToSearch,
syncToUrl: lockedType === undefined,
});
const typeFilter = lockedType ?? filters.type ?? "ALL";
// v1.15.13 — management-list source filter + optional date range.
// `ALL` clears the source filter; empty date strings clear the bound.
const [sourceFilter, setSourceFilterRaw] = useState<string>("ALL");
const [fromDay, setFromDayRaw] = useState<string>("");
const [toDay, setToDayRaw] = useState<string>("");
// v1.18.5 — value-range filter (backlog G). Raw input strings; empty
// means the bound is unset (open-ended on that side).
const [valueMinInput, setValueMinInputRaw] = useState<string>("");
const [valueMaxInput, setValueMaxInputRaw] = useState<string>("");
const sourceFilter = filters.source ?? "ALL";
const fromDay = filters.fromDay ?? "";
const toDay = filters.toDay ?? "";
// v1.18.5 — value-range filter (backlog G). The inputs fire per keystroke,
// so they edit a local draft that debounces into the URL (200 ms, replace —
// never a history entry per keystroke); an external URL change (back
// button, deep link) re-seeds the draft via the render-phase derived-state
// adjustment below, exactly like the vault's search field.
const urlValueRange = `${filters.valueMin ?? ""}${filters.valueMax ?? ""}`;
const [valueDraft, setValueDraft] = useState(() => ({
min: filters.valueMin ?? "",
max: filters.valueMax ?? "",
}));
const [lastUrlValueRange, setLastUrlValueRange] = useState(urlValueRange);
if (urlValueRange !== lastUrlValueRange) {
setLastUrlValueRange(urlValueRange);
setValueDraft({
min: filters.valueMin ?? "",
max: filters.valueMax ?? "",
});
}
const valueMinInput = valueDraft.min;
const valueMaxInput = valueDraft.max;
const [page, setPage] = useState(1);
// Shared column-sort state (measuredAt opens descending).
const {
@@ -417,42 +438,63 @@ export function MeasurementList({
// are about to unmount).
const clearSelection = () => setSelectedIds(new Set());
// Discrete facet changes push (Back steps through filter states, like the
// vault's chips); the committed date bounds replace — a from/to pair set in
// one sitting should not cost two Back presses.
const setTypeFilter = (value: string) => {
setTypeFilterRaw(value);
applyFilters({ ...filters, type: value === "ALL" ? undefined : value });
setPage(1);
clearSelection();
};
const setSourceFilter = (value: string) => {
setSourceFilterRaw(value);
applyFilters({ ...filters, source: value === "ALL" ? undefined : value });
setPage(1);
clearSelection();
};
const setFromDay = (value: string) => {
setFromDayRaw(value);
applyFilters({ ...filters, fromDay: value || undefined }, "replace");
setPage(1);
clearSelection();
};
const setToDay = (value: string) => {
setToDayRaw(value);
applyFilters({ ...filters, toDay: value || undefined }, "replace");
setPage(1);
clearSelection();
};
const setValueMinInput = (value: string) => {
setValueMinInputRaw(value);
setValueDraft((prev) => ({ ...prev, min: value }));
setPage(1);
clearSelection();
};
const setValueMaxInput = (value: string) => {
setValueMaxInputRaw(value);
setValueDraft((prev) => ({ ...prev, max: value }));
setPage(1);
clearSelection();
};
// The value-range draft debounces into the URL; a bound that does not
// normalise to a finite number never reaches it (matching the previous
// in-memory behaviour, where non-numeric input meant "unset").
useEffect(() => {
const handle = setTimeout(() => {
const min = numericInputOrUndefined(valueDraft.min);
const max = numericInputOrUndefined(valueDraft.max);
if (
(min ?? "") === (filters.valueMin ?? "") &&
(max ?? "") === (filters.valueMax ?? "")
) {
return;
}
applyFilters({ ...filters, valueMin: min, valueMax: max }, "replace");
}, 200);
return () => clearTimeout(handle);
}, [valueDraft, filters, applyFilters]);
const goToPage = (updater: (p: number) => number) => {
setPage(updater);
clearSelection();
@@ -494,13 +536,9 @@ export function MeasurementList({
// TIME-ASLEEP), where filtering on a raw `value` column carries no
// meaning, so the range pill is hidden for those types.
const valueMin =
valueMinInput.trim() !== "" && Number.isFinite(Number(valueMinInput))
? Number(valueMinInput)
: undefined;
filters.valueMin !== undefined ? Number(filters.valueMin) : undefined;
const valueMax =
valueMaxInput.trim() !== "" && Number.isFinite(Number(valueMaxInput))
? Number(valueMaxInput)
: undefined;
filters.valueMax !== undefined ? Number(filters.valueMax) : undefined;
const listMode: "raw" | "groupBy=day" | "sleep-night" = isSleepFilter
? "sleep-night"
: isCumulativeFilter
@@ -874,12 +912,14 @@ export function MeasurementList({
(valueMinInput !== "" || valueMaxInput !== ""))
}
onReset={() => {
setTypeFilter("ALL");
setSourceFilter("ALL");
setFromDay("");
setToDay("");
setValueMinInput("");
setValueMaxInput("");
// One push clears every facet (and so one Back restores them);
// the value-range draft is cleared directly because a bound
// that never committed to the URL has no change to re-seed
// from.
applyFilters({});
setValueDraft({ min: "", max: "" });
setPage(1);
clearSelection();
}}
count={
data?.meta?.total !== undefined
@@ -173,7 +173,7 @@ describe("<InjectionSitePicker>", () => {
);
expect(html).toContain(
'aria-label="Bauch, unten links – empfohlene nächste Stelle"',
'aria-label="Bauch, unten links — empfohlene nächste Stelle"',
);
expect(html).toContain('aria-label="Bauch, unten rechts"');
expect(html).toContain('aria-label="Bauch, oben links"');
@@ -0,0 +1,118 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import en from "../../../../messages/en.json";
/**
* The expandable per-item answer breakdown (the read side of
* `responsesEncrypted`). SSR-only convention (`renderToStaticMarkup`) — the
* detail query is seeded into the cache, the fetch never runs.
*
* Pins: the official item texts render from the SAME `mentalHealth.items.*`
* keys the check-in wizard uses (validated instrument copy, never re-worded),
* each answer shows its option label + value, PHQ-9 item 9 gets NO alarm
* styling, and the server's decrypt-failure degrade renders as the honest
* unavailable line rather than a blank.
*/
vi.mock("@/lib/api/api-fetch", () => ({
apiGet: () => new Promise(() => {}),
apiPost: () => new Promise(() => {}),
}));
import { AssessmentItemBreakdown } from "../assessment-item-breakdown";
import type { AssessmentDetail } from "../types";
const mh = en.mentalHealth;
function detailRow(overrides: Partial<AssessmentDetail>): AssessmentDetail {
return {
id: "mha_1",
instrument: "PHQ9",
locale: "en",
totalScore: 10,
severityBand: "moderate",
item9Flagged: true,
crisisShownAt: null,
takenAt: "2026-06-28T00:00:00.000Z",
items: [1, 1, 1, 1, 1, 1, 1, 1, 2],
functionalDifficulty: null,
itemsUnavailable: false,
...overrides,
};
}
function render(detail: AssessmentDetail): string {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
queryClient.setQueryData(queryKeys.mentalHealthAssessmentDetail(detail.id), {
assessment: detail,
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<AssessmentItemBreakdown
assessmentId={detail.id}
instrument={detail.instrument}
/>
</I18nProvider>
</QueryClientProvider>,
);
}
describe("AssessmentItemBreakdown", () => {
it("renders every official PHQ-9 item text with its answer label + value", () => {
const html = render(detailRow({}));
// All nine validated item texts, from the wizard's own keys.
for (let n = 1; n <= 9; n++) {
expect(html).toContain(
mh.items.phq9[String(n) as keyof typeof mh.items.phq9],
);
}
// Answer labels are the shared PHQ/GAD anchors.
expect(html).toContain(mh.options["1"]);
expect(html).toContain(mh.options["2"]);
});
it("gives PHQ-9 item 9 no alarm styling — it renders like every other item", () => {
const html = render(detailRow({}));
expect(html).toContain(mh.items.phq9["9"]);
expect(html).not.toContain("text-destructive");
});
it("renders the unscored functional follow-up when it was answered", () => {
const html = render(detailRow({ functionalDifficulty: 2 }));
expect(html).toContain(mh.functionalTitle);
expect(html).toContain(mh.functional["2"]);
});
it("omits the functional follow-up row when it was not answered", () => {
const html = render(detailRow({ functionalDifficulty: null }));
expect(html).not.toContain(mh.functionalTitle);
});
it("paints the WHO-5 recall stem once above its run of items", () => {
const html = render(
detailRow({
instrument: "WHO5",
items: [5, 4, 3, 2, 1],
item9Flagged: false,
}),
);
// The shared stem appears exactly once, not per item.
const stem = mh.stems.who5.period;
expect(html.split(stem).length - 1).toBe(1);
expect(html).toContain(mh.items.who5["1"]);
expect(html).toContain(mh.who5Options["5"]);
});
it("renders the honest unavailable line on the server's decrypt degrade", () => {
const html = render(detailRow({ items: null, itemsUnavailable: true }));
expect(html).toContain(mh.history.answersUnavailable);
expect(html).not.toContain(mh.items.phq9["1"]);
});
});
@@ -15,6 +15,11 @@
* the row's stored locale (`crisisResourcesForLocale`) — it NEVER decrypts or
* reveals the item-9 answer, and surfacing it triggers no third-party alert.
* No new data leaves the safety boundary.
*
* Each row also carries an opt-in answer breakdown: expanding it fetches the
* per-administration detail read, where the server decrypts the stored
* per-item answers (`AssessmentItemBreakdown`). Item 9 renders there like any
* other item — the flagged marker above stays the one crisis affordance.
*/
import { useMemo, useState } from "react";
import dynamic from "next/dynamic";
@@ -28,8 +33,10 @@ import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { importWithRetry } from "@/lib/retry-import";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { crisisResourcesForLocale } from "@/lib/mental-health/crisis-resources";
import { Activity } from "lucide-react";
import { Activity, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { AssessmentItemBreakdown } from "./assessment-item-breakdown";
import { CrisisCard } from "./crisis-card";
import type { AssessmentRow, CrisisSet, InstrumentId } from "./types";
@@ -80,6 +87,11 @@ export function AssessmentHistory({
const { date: formatDate } = useFormatters();
// Which flagged row's crisis card is currently expanded (re-surfaced).
const [openCrisisRowId, setOpenCrisisRowId] = useState<string | null>(null);
// Which row's per-item answer breakdown is expanded (single-open, like the
// crisis card — the detail fetch runs only when someone asks).
const [openBreakdownRowId, setOpenBreakdownRowId] = useState<string | null>(
null,
);
const forInstrument = useMemo(
() =>
@@ -116,6 +128,7 @@ export function AssessmentHistory({
{forInstrument.map((row) => {
const flagged = row.item9Flagged;
const open = openCrisisRowId === row.id;
const breakdownOpen = openBreakdownRowId === row.id;
return (
<li key={row.id} className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-3 text-sm">
@@ -146,9 +159,37 @@ export function AssessmentHistory({
<span className="font-medium tabular-nums">
{row.totalScore}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-auto p-0.5"
aria-expanded={breakdownOpen}
aria-label={t("mentalHealth.history.answersToggle")}
onClick={() =>
setOpenBreakdownRowId(breakdownOpen ? null : row.id)
}
data-slot="history-answers-toggle"
>
<ChevronDown
aria-hidden="true"
className={cn(
"text-muted-foreground size-4 transition-transform",
breakdownOpen && "rotate-180",
)}
/>
</Button>
</div>
</div>
{flagged && open && <CrisisCard crisis={crisisFromRow(row)} />}
{breakdownOpen && (
<div className="border-border/70 border-l pl-3">
<AssessmentItemBreakdown
assessmentId={row.id}
instrument={instrument}
/>
</div>
)}
</li>
);
})}
@@ -0,0 +1,136 @@
"use client";
/**
* Per-item answer breakdown for one recorded administration, expanded from a
* history row. Fetches the detail read (`GET …/assessments/[id]`), which is
* where the server decrypts `responsesEncrypted` — the answers never ride the
* list payload.
*
* Grammar: the detail surface's label/value rows — official item text muted
* (validated instrument copy via the SAME `mentalHealth.items.*` keys the
* check-in wizard renders; never re-worded here), the chosen answer label +
* its value in foreground. The WHO-5 / SCI recall stems paint once above each
* run of items, as on the source forms. PHQ-9 item 9 renders like every other
* item — no alarm colour; the row's existing "support shown" marker is the
* crisis affordance and is not duplicated here.
*/
import { useQuery } from "@tanstack/react-query";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import {
INSTRUMENTS,
optionLabelKey,
stemKey,
} from "@/lib/mental-health/instruments";
import type { AssessmentDetail, InstrumentId } from "./types";
export function AssessmentItemBreakdown({
assessmentId,
instrument,
}: {
assessmentId: string;
instrument: InstrumentId;
}) {
const { t } = useTranslations();
const def = INSTRUMENTS[instrument];
const { data, isPending, isError } = useQuery({
queryKey: queryKeys.mentalHealthAssessmentDetail(assessmentId),
queryFn: () =>
apiGet<{ assessment: AssessmentDetail }>(
`/api/mental-health/assessments/${assessmentId}`,
),
});
if (isPending) {
return (
<div
className="flex flex-col gap-2"
data-slot="assessment-item-breakdown"
aria-busy="true"
>
{Array.from({ length: def.itemCount }, (_, i) => (
<Skeleton key={i} className="h-4 w-full" />
))}
</div>
);
}
if (isError || !data) {
return (
<p
className="text-destructive text-sm"
role="alert"
data-slot="assessment-item-breakdown"
>
{t("mentalHealth.error")}
</p>
);
}
const detail = data.assessment;
if (detail.items === null) {
// The server's honest degrade: the blob exists but cannot be decrypted
// (key gap / corruption). The score above this breakdown still answers.
return (
<p
className="text-muted-foreground text-sm"
data-slot="assessment-item-breakdown"
>
{t("mentalHealth.history.answersUnavailable")}
</p>
);
}
return (
<div
className="flex flex-col gap-2 text-sm"
data-slot="assessment-item-breakdown"
>
{detail.items.map((value, index) => {
// Stem captions group runs of items exactly as the wizard presents
// them: paint the stem only where it differs from the previous item's.
const stem = stemKey(instrument, index);
const caption =
stem !== (index > 0 ? stemKey(instrument, index - 1) : null)
? stem
: null;
return (
<div key={index} className="flex flex-col gap-1">
{caption && (
<p className="text-muted-foreground pt-1 text-xs">
{t(`mentalHealth.stems.${caption}`)}
</p>
)}
<div className="flex items-baseline justify-between gap-3">
<span className="text-muted-foreground min-w-0 flex-1">
{t(`mentalHealth.items.${def.i18nKey}.${index + 1}`)}
</span>
<span className="text-foreground shrink-0 text-right">
{t(optionLabelKey(instrument, index, value))}
{" · "}
<span className="tabular-nums">{value}</span>
</span>
</div>
</div>
);
})}
{detail.functionalDifficulty !== null && (
<div className="flex items-baseline justify-between gap-3">
<span className="text-muted-foreground min-w-0 flex-1">
{t("mentalHealth.functionalTitle")}
</span>
{/* Unscored follow-up: the label alone, no value — it never counts
into the total and a number would imply it does. */}
<span className="text-foreground shrink-0 text-right">
{t(`mentalHealth.functional.${detail.functionalDifficulty}`)}
</span>
</div>
)}
</div>
);
}
+15 -2
View File
@@ -4,8 +4,9 @@
* The 387-line monolith split into a folder of focused components; these are
* the seams they plug into. The wire shapes mirror the assessment route's
* `shapeRow` / create response (`src/app/api/mental-health/assessments/route.ts`)
* — totals + bands + flags only, NEVER item content (the item answers stay
* encrypted at rest and never ride this surface).
* — totals + bands + flags only on the list. The per-item answers ride ONLY
* the per-administration detail read (`…/assessments/[id]`), which decrypts
* them for the history's expandable answer breakdown.
*/
import type { InstrumentId } from "@/lib/mental-health/instruments";
@@ -25,6 +26,18 @@ export interface AssessmentRow {
takenAt: string;
}
/**
* One administration with its decrypted answers (`GET …/assessments/[id]`).
* `items: null` + `itemsUnavailable` is the server's honest degrade for a row
* whose blob cannot be decrypted — the score fields still answer.
*/
export interface AssessmentDetail extends AssessmentRow {
items: number[] | null;
/** The unscored PHQ-9 functional follow-up, when it was answered. */
functionalDifficulty: number | null;
itemsUnavailable: boolean;
}
export interface CrisisSet {
emergencyNumber: string;
resources: { id: string; contacts: string[] }[];
@@ -35,6 +35,7 @@ const baseEntries = [
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/mood",
useSearchParams: () => new URLSearchParams(""),
}));
vi.mock("@tanstack/react-query", () => ({
@@ -0,0 +1,68 @@
/**
* URL round trip for the mood list filters (`?mood&source&from&to`) — the
* vault-utils contract applied to this surface: parsing is lenient (a
* hand-edited URL drops the invalid facet, never breaks the page),
* serialisation omits defaults, and parse(serialise(x)) is identity so deep
* links stay stable.
*/
import { describe, expect, it } from "vitest";
import {
moodListFiltersToSearch,
parseMoodListSearchParams,
type MoodListFilters,
} from "../mood-list-filters";
describe("parseMoodListSearchParams", () => {
it.each<[string, MoodListFilters]>([
["", {}],
["mood=GUT", { mood: "GUT" }],
["mood=LAUSIG&source=TELEGRAM", { mood: "LAUSIG", source: "TELEGRAM" }],
[
"from=2026-05-01&to=2026-05-31",
{ fromDay: "2026-05-01", toDay: "2026-05-31" },
],
// MOODLOG is legacy provenance — rows keep their label, so the filter
// must keep resolving it.
["source=MOODLOG", { source: "MOODLOG" }],
])("parses %j", (search, expected) => {
expect(parseMoodListSearchParams(new URLSearchParams(search))).toEqual(
expected,
);
});
it("drops facets a stale or hand-edited URL cannot honour", () => {
expect(
parseMoodListSearchParams(
new URLSearchParams(
"mood=ECSTATIC&source=NOT_A_SOURCE&from=05-01-2026&to=yesterday",
),
),
).toEqual({});
});
});
describe("moodListFiltersToSearch", () => {
it("serialises the empty filter set to an empty string (bare URL)", () => {
expect(moodListFiltersToSearch({})).toBe("");
});
it.each<MoodListFilters>([
{},
{ mood: "SUPER_GUT" },
{ mood: "OKAY", source: "WEB" },
{ fromDay: "2026-05-01", toDay: "2026-05-31" },
{
mood: "SCHLECHT",
source: "DAYLIO",
fromDay: "2026-01-01",
toDay: "2026-12-31",
},
])("round-trips %j", (filters) => {
expect(
parseMoodListSearchParams(
new URLSearchParams(moodListFiltersToSearch(filters)),
),
).toEqual(filters);
});
});
@@ -0,0 +1,194 @@
/**
* URL-owned filter state on the mood management list (the documents-vault
* pattern via `useUrlFilterSync`), mirroring the measurements-list guard:
* a filter change writes the serialised facet set to the page URL, and
* mounting with a query string restores every facet into the rail's
* controls and the list's TanStack query key.
*
* SSR-only per project convention: the rail primitives are stubbed with
* prop-capturing markers, and the captured callbacks stand in for the
* user's click (post-render setState is a no-op under the server renderer;
* the router spies record the navigation the click causes).
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const baseEntries = [
{
id: "e-1",
date: "2026-05-09",
mood: "GUT",
score: 4,
tags: [],
tagKeys: [],
note: null,
source: "MANUAL",
moodLoggedAt: "2026-05-09T20:00:00.000Z",
},
];
let mockSearch = "";
const pushMock = vi.fn();
const replaceMock = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock, replace: replaceMock }),
usePathname: () => "/mood",
useSearchParams: () => new URLSearchParams(mockSearch),
}));
const recordedQueryKeys: unknown[] = [];
vi.mock("@tanstack/react-query", () => ({
useQuery: (options: { queryKey: unknown }) => {
recordedQueryKeys.push(options.queryKey);
return {
data: { entries: baseEntries, meta: { total: 1 } },
isLoading: false,
};
},
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isError: false,
error: null,
}),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", username: "testuser", role: "USER" },
isAuthenticated: true,
isLoading: false,
}),
}));
// Prop-capturing stubs for the rail primitives (render order in the list:
// date range, mood, source).
interface SelectProps {
label: string;
value: string;
onValueChange: (value: string) => void;
}
interface RangeProps {
from: string;
to: string;
onFromChange: (value: string) => void;
onToChange: (value: string) => void;
}
let selects: SelectProps[] = [];
let barProps: { isFiltered: boolean; onReset: () => void } | null = null;
let dateRange: RangeProps | null = null;
vi.mock("@/components/ui/filter-bar", () => ({
FilterBar: (props: {
isFiltered: boolean;
onReset: () => void;
children?: unknown;
}) => {
barProps = { isFiltered: props.isFiltered, onReset: props.onReset };
return <div data-slot="filter-bar">{props.children as never}</div>;
},
FilterBarSelect: (props: SelectProps) => {
selects.push(props);
return <span data-slot="filter-bar-pill" />;
},
FilterBarDateRange: (props: RangeProps) => {
dateRange = props;
return <span data-slot="filter-bar-pill" />;
},
}));
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { MoodList } from "../mood-list";
function render(search: string) {
mockSearch = search;
selects = [];
barProps = null;
dateRange = null;
recordedQueryKeys.length = 0;
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MoodList />
</I18nProvider>,
);
}
const moodSelect = () => selects[0]!;
const sourceSelect = () => selects[1]!;
beforeEach(() => {
pushMock.mockClear();
replaceMock.mockClear();
});
describe("MoodList — filter changes write the URL", () => {
it("pushes the mood facet", () => {
render("");
moodSelect().onValueChange("GUT");
expect(pushMock).toHaveBeenCalledWith("/mood?mood=GUT", { scroll: false });
});
it("pushes the source facet on top of the active mood", () => {
render("mood=GUT");
sourceSelect().onValueChange("TELEGRAM");
expect(pushMock).toHaveBeenCalledWith("/mood?mood=GUT&source=TELEGRAM", {
scroll: false,
});
});
it("replaces (no history entry) for a committed date bound", () => {
render("");
dateRange!.onToChange("2026-05-31");
expect(replaceMock).toHaveBeenCalledWith("/mood?to=2026-05-31", {
scroll: false,
});
expect(pushMock).not.toHaveBeenCalled();
});
it("clearing the last facet restores the bare pathname", () => {
render("mood=GUT");
moodSelect().onValueChange("ALL");
expect(pushMock).toHaveBeenCalledWith("/mood", { scroll: false });
});
it("reset clears every facet in one push", () => {
render("mood=GUT&source=TELEGRAM&from=2026-05-01");
expect(barProps!.isFiltered).toBe(true);
barProps!.onReset();
expect(pushMock).toHaveBeenCalledWith("/mood", { scroll: false });
});
});
describe("MoodList — mounting with a query string restores the filters", () => {
it("seeds every rail control from the URL", () => {
render("mood=OKAY&source=MOODLOG&from=2026-05-01&to=2026-05-31");
expect(moodSelect().value).toBe("OKAY");
expect(sourceSelect().value).toBe("MOODLOG");
expect(dateRange!.from).toBe("2026-05-01");
expect(dateRange!.to).toBe("2026-05-31");
expect(barProps!.isFiltered).toBe(true);
});
it("keys the list query off the restored facets", () => {
render("mood=OKAY&source=MOODLOG&from=2026-05-01&to=2026-05-31");
expect(recordedQueryKeys).toContainEqual(
queryKeys.moodEntriesList({
mood: "OKAY",
source: "MOODLOG",
from: "2026-05-01",
to: "2026-05-31",
page: 1,
sortBy: "moodLoggedAt",
sortDir: "desc",
}),
);
});
it("drops an invalid facet instead of breaking the page", () => {
render("mood=ECSTATIC&source=WEB");
expect(moodSelect().value).toBe("ALL");
expect(sourceSelect().value).toBe("WEB");
});
});
@@ -46,6 +46,7 @@ const baseEntries = [
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/mood",
useSearchParams: () => new URLSearchParams(""),
}));
vi.mock("@tanstack/react-query", () => ({
+1 -1
View File
@@ -384,7 +384,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
className="h-7 w-7 sm:h-8 sm:w-8"
aria-hidden="true"
/>
<span className="text-[10px] leading-tight sm:text-xs">
<span className="text-2xs leading-tight sm:text-xs">
{t(level.labelKey)}
</span>
</button>
+69
View File
@@ -0,0 +1,69 @@
/**
* Pure URL-facet helpers for the mood management list, following the
* documents-vault pattern (`vault-utils.ts`): the list's filter state lives
* in the page URL (`?mood&source&from&to`) so a filtered view is
* deep-linkable and survives navigation, reload and back/forward. Parsing is
* lenient — a hand-edited or stale URL drops the invalid facet rather than
* breaking the page — and serialisation omits defaults so the unfiltered
* list keeps a bare URL. Kept out of the client component so the round trip
* stays trivially unit-testable without a render harness.
*/
import { moodLevelEnum, moodSourceEnum } from "@/lib/validations/mood";
export interface MoodListFilters {
/** `MoodLevel` enum value; absent = all moods. */
mood?: string;
/** `MoodSource` enum value; absent = all sources. */
source?: string;
/** Inclusive day bounds, `YYYY-MM-DD` (the date inputs' committed values). */
fromDay?: string;
toDay?: string;
}
const MOOD_SET = new Set<string>(moodLevelEnum.options);
const SOURCE_SET = new Set<string>(moodSourceEnum.options);
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
function dayOrUndefined(value: string | null): string | undefined {
const trimmed = value?.trim();
return trimmed && DAY_PATTERN.test(trimmed) ? trimmed : undefined;
}
/**
* Parse the list's URL search params (`?mood&source&from&to`) into the
* filter object. Unknown moods/sources and malformed days are dropped, so a
* stale deep link never traps the user on a broken filter.
*/
export function parseMoodListSearchParams(
params: URLSearchParams,
): MoodListFilters {
const filters: MoodListFilters = {};
const mood = params.get("mood")?.trim();
if (mood && MOOD_SET.has(mood)) filters.mood = mood;
const source = params.get("source")?.trim();
if (source && SOURCE_SET.has(source)) filters.source = source;
const fromDay = dayOrUndefined(params.get("from"));
if (fromDay) filters.fromDay = fromDay;
const toDay = dayOrUndefined(params.get("to"));
if (toDay) filters.toDay = toDay;
return filters;
}
/**
* Serialise the filter object back into the page URL's search string (no
* leading `?`; empty string for the default view). Inverse of
* `parseMoodListSearchParams`; round-tripping is pinned by test.
*/
export function moodListFiltersToSearch(filters: MoodListFilters): string {
const sp = new URLSearchParams();
if (filters.mood) sp.set("mood", filters.mood);
if (filters.source) sp.set("source", filters.source);
if (filters.fromDay) sp.set("from", filters.fromDay);
if (filters.toDay) sp.set("to", filters.toDay);
return sp.toString();
}
+29 -13
View File
@@ -59,6 +59,11 @@ import {
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTableSort } from "@/hooks/use-table-sort";
import { useUrlFilterSync } from "@/hooks/use-url-filter-sync";
import {
moodListFiltersToSearch,
parseMoodListSearchParams,
} from "./mood-list-filters";
// Columns that open descending when first selected. The logged-at column
// reads newest-first by default; every other column opens ascending.
@@ -174,11 +179,19 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
// list stays readable and every control that would change it is absent.
const { canManage } = useRecordCapabilities();
const queryClient = useQueryClient();
const [moodFilter, setMoodFilterRaw] = useState<string>("ALL");
// URL-owned filter state (the documents-vault pattern): mood, source and
// date range parse from `?mood&source&from&to` and every change writes back
// through the router, so a filtered view survives navigation, reload and
// back/forward.
// v1.15.13 — management-list source filter + optional date range.
const [sourceFilter, setSourceFilterRaw] = useState<string>("ALL");
const [fromDay, setFromDayRaw] = useState<string>("");
const [toDay, setToDayRaw] = useState<string>("");
const { filters, applyFilters } = useUrlFilterSync({
parse: parseMoodListSearchParams,
serialise: moodListFiltersToSearch,
});
const moodFilter = filters.mood ?? "ALL";
const sourceFilter = filters.source ?? "ALL";
const fromDay = filters.fromDay ?? "";
const toDay = filters.toDay ?? "";
const [page, setPage] = useState(1);
// Shared column-sort state (moodLoggedAt opens descending).
const {
@@ -268,26 +281,29 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
// clears the page-scoped selection.
const clearSelection = () => setSelectedIds(new Set());
// Discrete facet changes push (Back steps through filter states, like the
// vault's chips); the committed date bounds replace — a from/to pair set in
// one sitting should not cost two Back presses.
const setMoodFilter = (value: string) => {
setMoodFilterRaw(value);
applyFilters({ ...filters, mood: value === "ALL" ? undefined : value });
setPage(1);
clearSelection();
};
const setSourceFilter = (value: string) => {
setSourceFilterRaw(value);
applyFilters({ ...filters, source: value === "ALL" ? undefined : value });
setPage(1);
clearSelection();
};
const setFromDay = (value: string) => {
setFromDayRaw(value);
applyFilters({ ...filters, fromDay: value || undefined }, "replace");
setPage(1);
clearSelection();
};
const setToDay = (value: string) => {
setToDayRaw(value);
applyFilters({ ...filters, toDay: value || undefined }, "replace");
setPage(1);
clearSelection();
};
@@ -624,10 +640,10 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
toDay !== ""
}
onReset={() => {
setMoodFilter("ALL");
setSourceFilter("ALL");
setFromDay("");
setToDay("");
// One push clears every facet, so one Back restores them.
applyFilters({});
setPage(1);
clearSelection();
}}
count={
data?.meta?.total !== undefined
@@ -1048,7 +1064,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
<span className="text-lg font-semibold tabular-nums">
{MOOD_SCORES[level]}
</span>
<span className="text-[10px] leading-tight sm:text-xs">
<span className="text-2xs leading-tight sm:text-xs">
{t(MOOD_LABEL_KEYS[level])}
</span>
</button>
@@ -72,7 +72,7 @@ describe("<SourceCardGrid> Withings card", () => {
expect(html).toContain('data-testid="source-card-withings-setup"');
expect(html).toContain('href="/settings/integrations#withings"');
expect(html).not.toContain('href="/api/withings/connect"');
expect(html).toContain("Set up in Settings");
expect(html).toContain("Set up</a>");
});
it("also redirects to Settings when the status envelope hasn't loaded yet", () => {
@@ -112,3 +112,23 @@ describe("<SourceCardGrid> Withings card", () => {
expect(html).not.toContain("Connect Withings");
});
});
// #778 — the step listed Apple Health next to the web connectors with nothing
// saying its setup happens in the iOS app afterwards, so it read like one more
// OAuth flow to hunt for.
describe("<SourceCardGrid> Apple Health note", () => {
it("says Apple Health setup completes in the iPhone app, not on the web", () => {
integrationStatusState.data = undefined;
const html = render();
expect(html).toContain('data-testid="source-more-apple-health-note"');
expect(html).toContain("open the HealthLog app on your iPhone");
expect(html).toContain("no web connection");
});
it("keeps the Apple Health chip tappable so the note is not a dead end", () => {
integrationStatusState.data = undefined;
const html = render();
expect(html).toContain('data-testid="source-more-apple-health"');
expect(html).toContain('href="/settings/integrations"');
});
});
@@ -222,6 +222,18 @@ export function SourceCardGrid() {
</li>
))}
</ul>
{/* #778 — Apple Health looked like one more web connector here, but
there is nothing to connect on the web: the sync is set up in the
iOS app after onboarding. Say so, so nobody hunts Settings for an
OAuth flow that does not exist. The chip stays tappable (it lands
on the Integrations page, whose Apple Health card walks through
the iPhone-app setup) and Next / Skip advance as always. */}
<p
className="text-muted-foreground text-xs leading-relaxed"
data-testid="source-more-apple-health-note"
>
{t("onboarding.source.more.appleHealthHint")}
</p>
</div>
<Link
@@ -32,6 +32,7 @@ import { SettingsCardActions } from "@/components/settings/_card-actions";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -207,6 +208,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
const dragHintId = useId();
const heroDescriptionId = useId();
const heroNotificationNoteId = useId();
const heroContentSelectId = useId();
// v1.34 — the "Today highlights" fieldset below is gated on `layout`,
// which can be truthy on the very first client render whenever a
// returning visitor's browser already has a warm, offline-persisted
@@ -393,6 +395,19 @@ export function DashboardLayoutSection({ id }: { id: string }) {
});
}
/**
* Hero primary content — the server-persisted `hero` field on the layout
* blob. The value set is closed ("score" | "reminders"); anything else a
* DOM event could carry collapses to the default rather than a cast.
*/
function setHeroContent(value: string) {
if (!layout) return;
setDraft({
...layout,
hero: value === "reminders" ? "reminders" : "score",
});
}
/**
* v1.4.48 M6a — the arrow buttons now delegate to `reorderWidgets()`
* so the swap-and-renumber logic lives in exactly one place. The
@@ -459,6 +474,30 @@ export function DashboardLayoutSection({ id }: { id: string }) {
<p id={heroDescriptionId} className="text-muted-foreground text-xs">
{t("dashboard.heroItemsDescription")}
</p>
{/* Hero primary content — a single labelled select beside the
highlight toggles. "score" keeps the health-score read;
"reminders" promotes the highlight rail into the hero slot. */}
<div className="space-y-1.5">
<Label
htmlFor={heroContentSelectId}
className="text-sm font-medium"
>
{t("dashboard.heroContentTitle")}
</Label>
<NativeSelect
id={heroContentSelectId}
value={layout.hero ?? "score"}
onChange={(event) => setHeroContent(event.target.value)}
disabled={saveMutation.isPending}
data-slot="hero-content-select"
className="sm:max-w-xs"
>
<option value="score">{t("dashboard.heroContentScore")}</option>
<option value="reminders">
{t("dashboard.heroContentReminders")}
</option>
</NativeSelect>
</div>
<div className="divide-border divide-y">
{PRIORITY_ITEM_KINDS.map((kind) => {
const inputId = `hero-item-${kind}`;
@@ -733,7 +772,8 @@ export function DashboardLayoutSection({ id }: { id: string }) {
JSON.stringify(layout.widgets) ===
JSON.stringify(DEFAULT_DASHBOARD_LAYOUT.widgets) &&
JSON.stringify(layout.enabledHeroItemKinds ?? PRIORITY_ITEM_KINDS) ===
JSON.stringify(DEFAULT_DASHBOARD_LAYOUT.enabledHeroItemKinds)
JSON.stringify(DEFAULT_DASHBOARD_LAYOUT.enabledHeroItemKinds) &&
(layout.hero ?? "score") === DEFAULT_DASHBOARD_LAYOUT.hero
? t("dashboard.layoutUsingDefaults")
: t("dashboard.layoutCustomized")}
</p>
@@ -20,6 +20,10 @@ type Payload = {
lastSeenAt: string;
stale: boolean;
}>;
syncProgress?: {
recordsAccepted: number;
oldestMeasuredAt: string | null;
} | null;
};
let statusPayload: Payload | undefined;
@@ -295,3 +299,108 @@ describe("<AppleHealthCard> — delivery diagnostic (#586)", () => {
expect(html).not.toContain("Last background sync");
});
});
// #778 — the first-run backfill used to be invisible: a user watching the web
// app had no way to tell "still flowing" from "looks dead but is working".
describe("<AppleHealthCard> — sync progress (#778)", () => {
beforeEach(() => {
statusPayload = undefined;
statusLoading = false;
statusError = false;
});
it("shows the accepted-row count and the oldest reading reached", () => {
statusPayload = {
lastSyncedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
syncHealth: { verdict: "fresh", since: null },
syncProgress: {
recordsAccepted: 12345,
oldestMeasuredAt: "2019-05-04T06:00:00.000Z",
},
};
const html = render();
expect(html).toContain('data-testid="apple-health-progress"');
expect(html).toContain('data-testid="apple-health-progress-received"');
// Locale-grouped integer, so the count reads as a number, not a code.
expect(html).toContain("12,345");
expect(html).toContain('data-testid="apple-health-progress-oldest"');
// The 2019 date renders with its year — the whole point is showing how
// far back the backfill has reached.
expect(html).toContain("2019");
});
it("reads as flowing while batches keep arriving", () => {
statusPayload = {
lastSyncedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
syncHealth: { verdict: "fresh", since: null },
syncProgress: { recordsAccepted: 100, oldestMeasuredAt: null },
};
const html = render();
expect(html).toContain('data-testid="apple-health-progress-state"');
expect(html).toContain('data-state="flowing"');
expect(html).toContain("still delivering");
});
it("waits honestly instead of inventing throttle state when batches pause", () => {
statusPayload = {
lastSyncedAt: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(),
syncHealth: { verdict: "fresh", since: null },
syncProgress: { recordsAccepted: 100, oldestMeasuredAt: null },
};
const html = render();
expect(html).toContain('data-state="waiting"');
// The server does not track the phone's throttle/queue state, so the copy
// says "waiting for the iPhone app", never a fabricated percentage or ETA.
expect(html).toContain("Waiting for the iPhone app to send more data");
expect(html).not.toContain("%");
});
it("renders no progress section before anything has arrived", () => {
statusPayload = {
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
syncProgress: { recordsAccepted: 0, oldestMeasuredAt: null },
};
const html = render();
// The delivery section's "no sync yet" line already owns the blank state.
expect(html).not.toContain('data-testid="apple-health-progress"');
});
it("renders no progress section when the server read failed", () => {
statusPayload = {
lastSyncedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
syncHealth: { verdict: "fresh", since: null },
syncProgress: null,
};
const html = render();
expect(html).not.toContain('data-testid="apple-health-progress"');
});
it("defines every progress key in all six locale catalogs", () => {
const requiredKeys = [
"title",
"receivedLabel",
"oldestLabel",
"flowing",
"waiting",
] as const;
for (const catalog of [en, de, es, fr, itMessages, pl]) {
const progress = catalog.settings.appleHealth.progress;
for (const key of requiredKeys) {
expect(progress[key]).toBeTypeOf("string");
expect(progress[key].length).toBeGreaterThan(0);
}
}
});
});
@@ -24,7 +24,7 @@ import {
} from "@/components/settings/integration-status-pill";
import { Button } from "@/components/ui/button";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type {
MetricFreshnessEntry,
SyncHealth,
@@ -44,8 +44,23 @@ interface HealthKitStatus {
lastBackgroundSyncAt?: string | null;
syncHealth?: SyncHealth;
metricFreshness?: MetricFreshnessEntry[];
/** Server-side backfill progress (#778); null when the read failed. */
syncProgress?: {
recordsAccepted: number;
oldestMeasuredAt: string | null;
} | null;
}
/**
* How long after the last accepted batch the progress line stops claiming
* data is flowing. The server tracks no device-side throttle or queue state,
* so the only honest signal is the age of the newest batch: within the hour
* reads as "still arriving", beyond it as "waiting for the iPhone app" — a
* backfill that iOS has parked and a backfill that finished look identical
* from here, and the copy says neither is an error.
*/
const PROGRESS_WAITING_AFTER_MS = 60 * 60 * 1000;
const TRIGGER_KEYS: Record<SyncTrigger, string> = {
foreground: "settings.appleHealth.delivery.trigger.foreground",
background: "settings.appleHealth.delivery.trigger.background",
@@ -119,6 +134,79 @@ function DeliveryDiagnostic({
);
}
/**
* First-run backfill progress (#778). During the initial HealthKit backfill
* the card said nothing about how much had arrived or how far back it
* reached, so a working first run looked broken. This renders the figures
* the server actually holds — accepted-row count and the oldest instant
* reached — plus one honest state line derived from the age of the newest
* batch. No ETA and no percentage: the remaining queue lives on the phone
* and the server cannot see it.
*/
function SyncProgress({
status,
t,
now,
}: {
status: HealthKitStatus;
t: (key: string, params?: Record<string, string | number>) => string;
/** Injectable reference instant, mirroring the pill's own prop. */
now?: Date;
}) {
const formatters = useFormatters();
const progress = status.syncProgress;
// Nothing accepted and nothing synced: the delivery section's "no sync yet"
// line already covers the blank state — a zero-row table would only repeat it.
if (!progress || (progress.recordsAccepted <= 0 && !status.lastSyncedAt)) {
return null;
}
const reference = now ?? new Date();
const lastBatchAge = status.lastSyncedAt
? Math.max(0, reference.getTime() - new Date(status.lastSyncedAt).getTime())
: null;
const flowing =
lastBatchAge !== null && lastBatchAge < PROGRESS_WAITING_AFTER_MS;
return (
<div className="space-y-2" data-testid="apple-health-progress">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("settings.appleHealth.progress.title")}
</h3>
<dl className="space-y-1 text-sm">
<div className="flex flex-wrap gap-x-2">
<dt className="text-muted-foreground">
{t("settings.appleHealth.progress.receivedLabel")}
</dt>
<dd data-testid="apple-health-progress-received">
{formatters.integer(progress.recordsAccepted)}
</dd>
</div>
{progress.oldestMeasuredAt ? (
<div className="flex flex-wrap gap-x-2">
<dt className="text-muted-foreground">
{t("settings.appleHealth.progress.oldestLabel")}
</dt>
<dd data-testid="apple-health-progress-oldest">
{formatters.date(new Date(progress.oldestMeasuredAt))}
</dd>
</div>
) : null}
</dl>
{lastBatchAge !== null ? (
<p
className="text-muted-foreground text-xs"
data-testid="apple-health-progress-state"
data-state={flowing ? "flowing" : "waiting"}
>
{flowing
? t("settings.appleHealth.progress.flowing")
: t("settings.appleHealth.progress.waiting")}
</p>
) : null}
</div>
);
}
export function AppleHealthCard({ enabled }: { enabled: boolean }) {
const { t } = useTranslations();
const {
@@ -175,6 +263,8 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) {
{showPill && <DeliveryDiagnostic status={status} t={t} />}
{showPill && <SyncProgress status={status} t={t} />}
<MetricFreshnessDisclosure
entries={status?.metricFreshness}
idPrefix="apple-health"
@@ -0,0 +1,109 @@
/**
* The shared URL-owned-filter hook (`use-url-filter-sync.ts`) — the
* documents-vault pattern extracted for the measurements and mood lists.
* Pinned here: mount parses the live query string, a filter change writes
* the serialised form back through the router (push by default, replace on
* request), an empty filter set restores the bare pathname, and
* `syncToUrl: false` detaches the state from the URL entirely.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
let mockSearch = "";
const pushMock = vi.fn();
const replaceMock = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock, replace: replaceMock }),
usePathname: () => "/measurements",
useSearchParams: () => new URLSearchParams(mockSearch),
}));
import { useUrlFilterSync } from "../use-url-filter-sync";
interface ProbeFilters {
type?: string;
source?: string;
}
function parse(params: URLSearchParams): ProbeFilters {
const filters: ProbeFilters = {};
const type = params.get("type");
if (type) filters.type = type;
const source = params.get("source");
if (source) filters.source = source;
return filters;
}
function serialise(filters: ProbeFilters): string {
const sp = new URLSearchParams();
if (filters.type) sp.set("type", filters.type);
if (filters.source) sp.set("source", filters.source);
return sp.toString();
}
interface ProbeResult {
filters: ProbeFilters;
applyFilters: (next: ProbeFilters, mode?: "push" | "replace") => void;
}
function mount(search: string, syncToUrl?: boolean): ProbeResult {
mockSearch = search;
// Ref-shaped capture cell: the render-scoped assignment happens inside a
// closure the control-flow analysis cannot see through.
const captured: { current: ProbeResult | null } = { current: null };
function Probe() {
captured.current = useUrlFilterSync<ProbeFilters>({
parse,
serialise,
syncToUrl,
});
return null;
}
renderToStaticMarkup(<Probe />);
if (!captured.current) throw new Error("probe did not render");
return captured.current;
}
beforeEach(() => {
pushMock.mockClear();
replaceMock.mockClear();
});
describe("useUrlFilterSync", () => {
it("parses the mounted query string into the filter object", () => {
const { filters } = mount("type=WEIGHT&source=WITHINGS");
expect(filters).toEqual({ type: "WEIGHT", source: "WITHINGS" });
});
it("writes a filter change to the URL via push by default", () => {
const { applyFilters } = mount("");
applyFilters({ type: "WEIGHT" });
expect(pushMock).toHaveBeenCalledWith("/measurements?type=WEIGHT", {
scroll: false,
});
expect(replaceMock).not.toHaveBeenCalled();
});
it("writes via replace when asked (high-frequency input)", () => {
const { applyFilters } = mount("");
applyFilters({ source: "MANUAL" }, "replace");
expect(replaceMock).toHaveBeenCalledWith("/measurements?source=MANUAL", {
scroll: false,
});
expect(pushMock).not.toHaveBeenCalled();
});
it("restores the bare pathname when every facet clears", () => {
const { applyFilters } = mount("type=WEIGHT");
applyFilters({});
expect(pushMock).toHaveBeenCalledWith("/measurements", { scroll: false });
});
it("syncToUrl: false ignores the URL and never navigates", () => {
const { filters, applyFilters } = mount("type=WEIGHT", false);
expect(filters).toEqual({});
applyFilters({ type: "PULSE" });
expect(pushMock).not.toHaveBeenCalled();
expect(replaceMock).not.toHaveBeenCalled();
});
});
+32 -1
View File
@@ -63,6 +63,15 @@ export interface MeasurementReminder {
location: string | null;
nextDueAt: string | null;
lastSatisfiedAt: string | null;
/**
* v1.37.20 (#223) — resolved skip/snooze state. The client only compares
* against the clock: snoozed = `snoozedUntil > now`; a skipped cycle =
* `lastSkippedAt > lastSatisfiedAt` (or set with no satisfy yet). Cadence
* is never recomputed here; `lastSatisfiedAt` stays the only "done".
*/
snoozedUntil: string | null;
lastSkippedAt: string | null;
skipCount: number;
enabled: boolean;
createdAt: string;
updatedAt: string;
@@ -129,5 +138,27 @@ export function useMeasurementReminderMutations() {
onSuccess: invalidate,
});
return { create, update, remove, satisfy };
// v1.37.20 (#223) — honest skip: the interval restarts from the skip
// instant server-side; lastSatisfiedAt is never touched.
const skip = useMutation({
mutationKey: queryKeys.measurementReminderSkip(),
mutationFn: (id: string) =>
apiPost<{ skipped: boolean; reminder: MeasurementReminder }>(
`${BASE}/${id}/skip`,
),
onSuccess: invalidate,
});
// v1.37.20 (#223) — snooze to a calendar day; the server resolves it to
// the notifyHour in the profile timezone and moves nextDueAt with it.
const snooze = useMutation({
mutationKey: queryKeys.measurementReminderSnooze(),
mutationFn: ({ id, until }: { id: string; until: string }) =>
apiPost<{ reminder: MeasurementReminder }>(`${BASE}/${id}/snooze`, {
until,
}),
onSuccess: invalidate,
});
return { create, update, remove, satisfy, skip, snooze };
}
+72
View File
@@ -0,0 +1,72 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
/**
* URL-owned filter state — the documents-vault pattern
* (`src/components/documents/documents-view.tsx`), extracted so the
* measurements and mood management lists carry the same contract: the page's
* filter object is parsed from the live query string and every change writes
* the serialised form back through the router, so a filtered view survives
* navigation, reload, back/forward and sharing.
*
* `parse` and `serialise` must be inverse, module-level functions (stable
* identities): round-tripping is what keeps a deep link and the UI agreeing,
* and `serialise` omits defaults so the unfiltered view keeps a bare URL.
*
* Mode grammar (matching the vault): a discrete facet change (`push`, the
* default) creates a history entry so Back steps through filter states, like
* the vault's kind/year chips; high-frequency typed input passes `replace` so
* the history never fills with keystrokes, like the vault's debounced search.
*
* `syncToUrl: false` keeps the identical contract in component memory only —
* for a list embedded under a URL that belongs to another surface (the
* type-locked insights readings subpage).
*/
export function useUrlFilterSync<T>({
parse,
serialise,
syncToUrl = true,
}: {
parse: (params: URLSearchParams) => T;
serialise: (filters: T) => string;
syncToUrl?: boolean;
}): {
filters: T;
applyFilters: (next: T, mode?: "push" | "replace") => void;
} {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const urlFilters = useMemo(
() => parse(new URLSearchParams(searchParams.toString())),
[parse, searchParams],
);
// Memory-only fallback for `syncToUrl: false`. The hook always mounts both
// sources (hooks are unconditional); only one is read.
const [localFilters, setLocalFilters] = useState<T>(() =>
parse(new URLSearchParams()),
);
const applyFilters = useCallback(
(next: T, mode: "push" | "replace" = "push") => {
if (!syncToUrl) {
setLocalFilters(next);
return;
}
const search = serialise(next);
const href = search ? `${pathname}?${search}` : pathname;
if (mode === "push") {
router.push(href, { scroll: false });
} else {
router.replace(href, { scroll: false });
}
},
[syncToUrl, serialise, pathname, router],
);
return { filters: syncToUrl ? urlFilters : localFilters, applyFilters };
}
@@ -1060,6 +1060,7 @@ describe("layout field merge disposition", () => {
selectedScoreRings: ["MED_COMPLIANCE"],
heroRingOrder: ["HEALTH_SCORE", "MED_COMPLIANCE"],
enabledHeroItemKinds: [...PRIORITY_ITEM_KINDS],
hero: "reminders",
};
it("assigns a disposition to every top-level layout field", () => {
@@ -1083,6 +1084,7 @@ describe("layout field merge disposition", () => {
"chartOverlayPrefs",
"comparisonBaseline",
"enabledHeroItemKinds",
"hero",
"heroRingOrder",
"selectedScoreRings",
"widgets",
@@ -1162,3 +1164,65 @@ describe("layout field merge disposition", () => {
expect(merged.widgets).toEqual(fullyPopulatedLayout.widgets);
});
});
/**
* Configurable hero primary content. The `hero` field chooses what the
* dashboard hero card leads with: the health-score read ("score", the
* long-standing default) or the Today-highlight rail promoted into the
* hero slot ("reminders"). Closed two-value enum, preserve-when-absent
* like the other layout preferences, unknown values dropped on read.
*/
describe("resolveDashboardLayout() — hero primary content", () => {
const baseWidgets: DashboardLayout["widgets"] = [
{ id: "weight", visible: true, tileVisible: true, order: 0 },
];
it("defaults to 'score' for legacy layouts (no field saved)", () => {
const layout = resolveDashboardLayout({ version: 1, widgets: baseWidgets });
expect(layout.hero).toBe("score");
});
it("defaults to 'score' on the default layout", () => {
expect(DEFAULT_DASHBOARD_LAYOUT.hero).toBe("score");
expect(resolveDashboardLayout(null).hero).toBe("score");
});
it("respects an explicit 'reminders' choice", () => {
const layout = resolveDashboardLayout({
version: 1,
widgets: baseWidgets,
hero: "reminders",
});
expect(layout.hero).toBe("reminders");
});
it("drops unknown hero values back to 'score'", () => {
for (const garbage of ["banner", 7, null, {}, ["reminders"]]) {
const layout = resolveDashboardLayout({
version: 1,
widgets: baseWidgets,
hero: garbage,
});
expect(layout.hero).toBe("score");
}
});
it("omits the default 'score' from serialization", () => {
const serialized = serializeDashboardLayout({
version: 1,
widgets: baseWidgets,
hero: "score",
});
expect("hero" in serialized).toBe(false);
});
it("persists 'reminders' through serialize → resolve round-trip", () => {
const serialized = serializeDashboardLayout({
version: 1,
widgets: baseWidgets,
hero: "reminders",
});
expect(serialized.hero).toBe("reminders");
expect(resolveDashboardLayout(serialized).hero).toBe("reminders");
});
});
+1 -1
View File
@@ -88,7 +88,7 @@ export const COACH_OUTBOUND_DOSE_BLOCK_DE =
export const COACH_OUTBOUND_RISK_BLOCK_EN =
"I can't put a number on a clinical risk like that — it isn't something your tracked data lets me compute, and an invented figure would be misleading. I can walk you through what your own measurements are doing instead; ask me about a specific metric.";
export const COACH_OUTBOUND_RISK_BLOCK_DE =
const COACH_OUTBOUND_RISK_BLOCK_DE =
"Eine solche klinische Risikozahl kann ich nicht angeben — deine erfassten Daten lassen das nicht berechnen, und ein erfundener Wert wäre irreführend. Stattdessen kann ich dir zeigen, was deine eigenen Messwerte machen; frag mich nach einer konkreten Metrik.";
/** Resolve the localised fallback copy for a tripped outbound screen. */
+1 -4
View File
@@ -40,10 +40,7 @@ import { getServerTranslator } from "@/lib/i18n/server-translator";
* exact wording without a translator round-trip and cannot drift from what the
* user is actually shown.
*/
export function coachRefusalCopy(
reason: CoachRefusalReason,
locale: Locale,
): string {
function coachRefusalCopy(reason: CoachRefusalReason, locale: Locale): string {
return getServerTranslator(locale).t(
reason === "prompt_injection"
? "coach.refusal.promptInjection"
@@ -1,11 +1,11 @@
/**
* The per-user recipe boundary, at the three places it exists.
* The per-user recipe boundary, at the two places it exists.
*
* The end-to-end proof that a settings change is never narrated as a
* health event lives in `config-change-is-not-a-health-event.test.ts`,
* over the real snapshot and digest. This file covers what that one
* cannot reach without a fixture per case: the boundary arithmetic's
* edges, the notice key's two dimensions, and the seam's shape.
* edges and the notice key's two dimensions.
*
* Each guard here has a counter-case beside it. A boundary that
* suppressed everything would pass every "no false drop" assertion ever
@@ -27,12 +27,6 @@ import {
scoreConfigBoundary,
UNCONFIGURED_SCORE_BOUNDARY,
} from "../config";
import {
scoreCompositeSegments,
scorePillarSeries,
scoreSeriesSeams,
type ScoreSeriesRecord,
} from "../series";
import type {
CompositeValue,
PillarValue,
@@ -337,140 +331,3 @@ describe("the notice key", () => {
);
});
});
describe("the seam", () => {
function day(
dayKey: string,
configVersion: number | null,
): ScoreSeriesRecord {
return {
dayKey,
composite: 70,
band: "yellow",
composition: ["ACTIVITY", "SLEEP", "ADIPOSITY"],
pillarScores: { ACTIVITY: 70, SLEEP: 70, ADIPOSITY: 70 },
configVersion,
};
}
it("marks the first day after a change and nothing else", () => {
const points = scoreSeriesSeams([
day("2026-08-17", 1),
day("2026-08-18", 1),
day("2026-08-19", 2),
day("2026-08-20", 2),
]);
expect(points.map((point) => point.seamBreak)).toEqual([
false,
false,
true,
false,
]);
});
it("never marks the first day of the series", () => {
// A beginning is not a break. Flagging it would put a "you changed
// what counts here" marker on the day someone started.
expect(scoreSeriesSeams([day("2026-08-20", 3)])[0].seamBreak).toBe(false);
expect(scoreSeriesSeams([])).toEqual([]);
});
it("stays quiet across days that share a recipe", () => {
const points = scoreSeriesSeams([
day("2026-08-18", 0),
day("2026-08-19", 0),
day("2026-08-20", 0),
]);
expect(points.some((point) => point.seamBreak)).toBe(false);
expect(scoreCompositeSegments(points)).toHaveLength(1);
});
it("orders by day before comparing, so an unordered read cannot invent a seam", () => {
const points = scoreSeriesSeams([
day("2026-08-20", 2),
day("2026-08-18", 1),
day("2026-08-19", 2),
]);
expect(points.map((point) => point.dayKey)).toEqual([
"2026-08-18",
"2026-08-19",
"2026-08-20",
]);
expect(points.map((point) => point.seamBreak)).toEqual([
false,
true,
false,
]);
});
it("treats a day that names no recipe as a break rather than a link", () => {
// Both directions, including two unknowns in a row. Without the
// second case the arm would be doing nothing that plain inequality
// does not already do: `null !== 1` breaks on its own, while
// `null !== null` would quietly claim two rows share a basis that
// neither of them names.
expect(
scoreSeriesSeams([day("2026-08-19", null), day("2026-08-20", 1)])[1]
.seamBreak,
).toBe(true);
expect(
scoreSeriesSeams([day("2026-08-19", 1), day("2026-08-20", null)])[1]
.seamBreak,
).toBe(true);
expect(
scoreSeriesSeams([day("2026-08-19", null), day("2026-08-20", null)])[1]
.seamBreak,
).toBe(true);
});
it("breaks the composite line and never rejoins it", () => {
const points = scoreSeriesSeams([
day("2026-08-17", 1),
day("2026-08-18", 1),
day("2026-08-19", 2),
day("2026-08-20", 2),
]);
const segments = scoreCompositeSegments(points);
expect(segments).toHaveLength(2);
expect(segments[0].map((point) => point.dayKey)).toEqual([
"2026-08-17",
"2026-08-18",
]);
expect(segments[1].map((point) => point.dayKey)).toEqual([
"2026-08-19",
"2026-08-20",
]);
// Every day is in exactly one segment: no day is repeated to make
// the ends meet, and none is dropped to hide the gap.
expect(segments.flat().map((point) => point.dayKey)).toEqual(
points.map((point) => point.dayKey),
);
});
it("keeps a pillar's own row running straight through the seam", () => {
const points = scoreSeriesSeams([
{ ...day("2026-08-18", 1), pillarScores: { SLEEP: 61 } },
{ ...day("2026-08-19", 2), pillarScores: { SLEEP: 62 } },
{ ...day("2026-08-20", 2), pillarScores: { SLEEP: 63 } },
]);
expect(points[1].seamBreak).toBe(true);
expect(scorePillarSeries(points, "SLEEP")).toEqual([
{ dayKey: "2026-08-18", score: 61 },
{ dayKey: "2026-08-19", score: 62 },
{ dayKey: "2026-08-20", score: 63 },
]);
});
it("leaves out a day the pillar did not count on, rather than scoring it zero", () => {
const points = scoreSeriesSeams([
{ ...day("2026-08-19", 1), pillarScores: { SLEEP: 61 } },
{ ...day("2026-08-20", 1), pillarScores: { ACTIVITY: 70 } },
]);
expect(scorePillarSeries(points, "SLEEP")).toEqual([
{ dayKey: "2026-08-19", score: 61 },
]);
});
});
+1 -1
View File
@@ -23,7 +23,7 @@ export const SCORE_MIN_ELIGIBLE_DOMAINS = 3;
* `WELLBEING` are the only two that do not, which is why a selection of
* those two alone yields no score however many domains it spans.
*/
export const PHYSIOLOGICAL_PILLARS: ReadonlySet<ScorePillarId> = new Set([
const PHYSIOLOGICAL_PILLARS: ReadonlySet<ScorePillarId> = new Set([
"BLOOD_PRESSURE",
"GLYCAEMIA",
"SLEEP",
+1 -1
View File
@@ -88,7 +88,7 @@ export const scorePillarIdSchema = z.enum(SCORE_PILLAR_IDS);
* REQUEST body is the strict end: an unknown id there is a 422, because
* a person's client has no business sending one.
*/
export const healthScoreConfigSchema = z.object({
const healthScoreConfigSchema = z.object({
excludedPillars: z.array(z.string().max(64)).max(64).optional(),
/** Per-user recipe version. Increments on every accepted write. */
version: z.number().int().min(1).optional(),
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff. Show more