feat: add the timeline page, its readiness inventory and life-event entry

The opt-in timeline module gets its page (#613). At 768 px and up the
record's lanes (life, conditions, allergies, medications, vaccinations,
doctor and procedures, labs, documents) are drawn in one SVG on a shared
time axis, with a year, month or week grid, open periods ending in an
arrow, a dashed lead-in where a start is unknown, dose marks and pauses on
the medication they belong to, a Today line and a selection line. Up to
three neutral value lines run below as monthly means, chosen in a menu.
Recharts stays for every value chart; this surface is plain SVG like the
heatmaps.

All geometry is pure (timeline-geometry.ts): window, scale, row packing
and label placement through one occupancy map, so no label overlaps
another label or a bar, none runs past the card edge, and labels that do
not fit are left out rather than squeezed. The width comes from a
ResizeObserver, so a docked day panel re-runs the layout.

The bar under the chart names the selected month's entries as chips, the
month's means and "Open 3 Jan.", which puts ?day= in the URL for the day
layer. The chart is one tab stop: arrows move the selection by a day, a
month or a year with the zoom, Home and End jump to the ends, Enter opens
the day. A visually hidden table lists every entry for screen readers.
The legend says once that stacked means at the same time, not cause and
effect.

Below 768 px the timeline is a chronicle, newest first by month or year,
with an Ongoing block for what has no meaningful start, rails beside the
rows an episode or course spans, and empty stretches named as such.

Switching the module on in Settings opens the readiness inventory: each
lane carries, is thin or is empty, with one link per gap and the verdict
as a count. The same inventory sits as a dismissible card on the timeline
while a gap is open, and under Layers as "Data coverage".

Life events are entered in a sheet (title, kind, a date to the day, month
or year, an optional end, an optional note) from the timeline, the
capture picker and the dashboard's add menu; one surface line maps the
capture kind to the timeline module, and it is offered in one's own
record only. Deleting asks first.

Server routes are built in parallel; the e2e journey answers them with the
contract's shapes. Copy in all seven locales; the timeline and lifeEvents
release-pending prefixes are gone.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 14:27:02 +02:00
1 parent b8f6372963
commit 32258bc5ab
50 files changed
+8277 -54

No files matched your search

+161 -7
View File
@@ -427,7 +427,9 @@
"workout": "Workout",
"workoutDescription": "Eine Einheit, die kein Gerät aufgezeichnet hat",
"symptom": "Symptom",
"symptomDescription": "Eines deiner eigenen Symptome, mit Stärke"
"symptomDescription": "Eines deiner eigenen Symptome, mit Stärke",
"lifeEventDescription": "Umzug, Geburt, neue Stelle",
"lifeEvent": "Lebensereignis"
},
"cycle": "Zyklus",
"labs": "Laborwerte",
@@ -578,6 +580,7 @@
"quickAddMood": "Stimmung erfassen",
"quickAddMedicationIntake": "Einnahme erfassen",
"quickAddWorkout": "Workout erfassen",
"quickAddLifeEvent": "Lebensereignis erfassen",
"customizeDashboard": "Dashboard anpassen",
"medicationIntakeQuickAdd": {
"sheetTitle": "Medikamenteneinnahme erfassen",
@@ -12214,8 +12217,8 @@
},
"timeline": {
"title": "Zeitleiste",
"subtitle": "Jahre auf einen Blick.",
"legend": "Nebeneinander gezeigt, nicht als Ursache und Wirkung.",
"subtitle": "Was wann war, neben deinen Werten.",
"legend": "Untereinander heißt gleichzeitig, nicht ursächlich.",
"standing": "Dauerhaft",
"startMissing": "Startdatum fehlt",
"openDay": "{date} öffnen",
@@ -12254,16 +12257,151 @@
"lanes": {
"values": "Werte",
"mood": "Stimmung",
"environment": "Umwelt"
"environment": "Umwelt",
"life": "Lebensereignisse"
},
"meter": "{carrying} von {total} Spuren tragen",
"carriesDetail": "{carrying} von {total} Spuren reichen für einen Überblick ab {since}.",
"loadFailed": "Die Bestandsaufnahme konnte nicht geladen werden.",
"card": {
"title": "Was die Zeitleiste schon trägt",
"summary": "Sie reicht bis {since} zurück, {carrying} von {total} Spuren tragen schon.",
"dismiss": "Ausblenden"
},
"detail": {
"valuesOne": "{count} Messgröße seit {since}",
"valuesFew": "{count} Messgrößen seit {since}",
"valuesOther": "{count} Messgrößen seit {since}",
"illnessOne": "{count} Episode, davon {chronic} chronisch",
"illnessFew": "{count} Episoden, davon {chronic} chronisch",
"illnessOther": "{count} Episoden, davon {chronic} chronisch",
"allergiesOne": "{count} Allergie",
"allergiesFew": "{count} Allergien",
"allergiesOther": "{count} Allergien",
"medicationsOne": "{count} Medikament, {missingStart} ohne Startdatum",
"medicationsFew": "{count} Medikamente, {missingStart} ohne Startdatum",
"medicationsOther": "{count} Medikamente, {missingStart} ohne Startdatum",
"medicationsDatedOne": "{count} Medikament mit Startdatum",
"medicationsDatedFew": "{count} Medikamente mit Startdatum",
"medicationsDatedOther": "{count} Medikamente mit Startdatum",
"vaccinationsOne": "{count} Impfung",
"vaccinationsFew": "{count} Impfungen",
"vaccinationsOther": "{count} Impfungen",
"visitsOne": "{count} Besuch · {procedures} mit Eingriff",
"visitsFew": "{count} Besuche · {procedures} mit Eingriff",
"visitsOther": "{count} Besuche · {procedures} mit Eingriff",
"visitsPlannedOne": "{count} Besuch, nur geplant",
"visitsPlannedFew": "{count} Besuche, nur geplant",
"visitsPlannedOther": "{count} Besuche, nur geplant",
"labsOne": "{count} Befundtag",
"labsFew": "{count} Befundtage",
"labsOther": "{count} Befundtage",
"documentsOne": "{count} Dokument mit Datum",
"documentsFew": "{count} Dokumente mit Datum",
"documentsOther": "{count} Dokumente mit Datum",
"lifeOne": "{count} Lebensereignis",
"lifeFew": "{count} Lebensereignisse",
"lifeOther": "{count} Lebensereignisse",
"cycleOne": "{count} Zyklus",
"cycleFew": "{count} Zyklen",
"cycleOther": "{count} Zyklen",
"moodOne": "{count} Eintrag seit {since}",
"moodFew": "{count} Einträge seit {since}",
"moodOther": "{count} Einträge seit {since}",
"environmentOne": "{count} Tag mit Umweltdaten",
"environmentFew": "{count} Tage mit Umweltdaten",
"environmentOther": "{count} Tage mit Umweltdaten",
"empty": "Noch keine"
},
"gap": {
"valuesEmpty": "Quellen verbinden",
"illnessEmpty": "Episode anlegen",
"medicationsWithoutStart": "Ergänzen",
"visitsEmpty": "Besuch nachtragen",
"documentsUndated": "Ansehen",
"labsEmpty": "Befund erfassen",
"vaccinationsEmpty": "Impfpass erfassen",
"lifeEventsEmpty": "Hinzufügen"
},
"gapLine": {
"medicationsWithoutStartOne": "{count} Medikament ohne Startdatum",
"medicationsWithoutStartFew": "{count} Medikamente ohne Startdatum",
"medicationsWithoutStartOther": "{count} Medikamente ohne Startdatum",
"documentsUndatedOne": "{count} Dokument ohne Datum",
"documentsUndatedFew": "{count} Dokumente ohne Datum",
"documentsUndatedOther": "{count} Dokumente ohne Datum",
"valuesEmpty": "Noch keine Werte über längere Zeit",
"illnessEmpty": "Noch keine Erkrankungen mit Beginn",
"visitsEmpty": "Noch keine erledigten Arztbesuche",
"labsEmpty": "Noch keine Laborbefunde",
"vaccinationsEmpty": "Noch keine Impfungen",
"lifeEventsEmpty": "Noch keine Lebensereignisse"
}
}
},
"legendSpan": "Zeitraum",
"legendEvent": "Ereignis",
"legendMean": "Monatsmittel",
"today": "Heute",
"noDataYet": "Noch keine Daten",
"layers": "Ebenen",
"layersLanes": "Spuren",
"zoomLabel": "Zeitraum der Ansicht",
"chartLabel": "Zeitleiste von {from} bis {to}",
"keyboardHint": "Pfeiltasten verschieben die Auswahl, Pos1 und Ende springen an die Enden, Eingabe öffnet den Tag.",
"loadFailed": "Die Zeitleiste konnte nicht geladen werden.",
"empty": {
"title": "Noch nichts auf der Zeitleiste",
"description": "Sobald es Einträge mit Datum gibt, erscheinen sie hier."
},
"values": {
"menuLabel": "Bis zu drei Werte",
"none": "Keine Werte",
"noneInWindow": "Keine Werte in diesem Zeitraum",
"series": {
"BLOOD_PRESSURE_SYS": "Blutdruck sys.",
"BLOOD_PRESSURE_DIA": "Blutdruck dia."
}
},
"table": {
"caption": "Einträge der Zeitleiste",
"lane": "Spur",
"entry": "Eintrag",
"start": "Beginn",
"end": "Ende"
},
"selection": {
"empty": "Keine Einträge in diesem Monat",
"ongoing": "laufend",
"range": "{from} bis {to}",
"mean": "Ø {values}"
},
"chronicle": {
"months": "Monate",
"years": "Jahre",
"groupingLabel": "Gruppierung",
"gap": "{from} bis {to} · keine Einträge",
"gapSingle": "{period} · keine Einträge",
"ended": "{label} vorbei",
"afterDaysOne": "nach einem Tag",
"afterDaysFew": "nach {count} Tagen",
"afterDaysOther": "nach {count} Tagen",
"notable": {
"extremeHigh": "Höchster Wert seit Langem",
"extremeLow": "Niedrigster Wert seit Langem",
"firstValue": "Erster Wert einer Messgröße",
"gap": "Längere Lücke in den Werten"
},
"notableMeta": "Tag öffnen für Einzelheiten"
},
"standingAllergiesOne": "{count} Allergie",
"standingAllergiesFew": "{count} Allergien",
"standingAllergiesOther": "{count} Allergien"
},
"lifeEvents": {
"title": "Lebensereignis",
"add": "Lebensereignis",
"subtitle": "Ein Anker für die Zeitleiste, kein Messwert.",
"privacy": "Titel und Notiz liegen verschlüsselt. Sie gehen an keine KI.",
"empty": "Noch keine Lebensereignisse",
"emptyHint": "Umzug, Geburt oder eine neue Stelle geben den Jahren einen Anker.",
"save": "Speichern",
"cancel": "Abbrechen",
@@ -12279,7 +12417,8 @@
"endDate": "Bis",
"note": "Notiz",
"optionalTag": "optional",
"notePlaceholder": "Etwas, das du später wissen willst"
"notePlaceholder": "Etwas, das du später wissen willst",
"removeEnd": "Enddatum entfernen"
},
"category": {
"FAMILY": "Familie",
@@ -12292,6 +12431,21 @@
"DAY": "Tag",
"MONTH": "Monat",
"YEAR": "Jahr"
},
"edit": "Lebensereignis bearbeiten",
"errors": {
"titleRequired": "Gib dem Ereignis einen Namen.",
"titleTooLong": "Höchstens 120 Zeichen.",
"categoryRequired": "Wähle eine Art.",
"dateRequired": "Gib ein gültiges Datum an.",
"endBeforeStart": "Das Ende liegt vor dem Beginn.",
"noteTooLong": "Höchstens 2000 Zeichen."
},
"saveFailed": "Speichern hat nicht geklappt.",
"deleteFailed": "Löschen hat nicht geklappt.",
"deleteConfirm": {
"title": "Lebensereignis löschen?",
"body": "Titel und Notiz sind danach nicht mehr da."
}
}
}