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": "Allenamento",
"workoutDescription": "Una sessione che i tuoi dispositivi non hanno registrato",
"symptom": "Sintomo",
"symptomDescription": "Uno dei tuoi sintomi, con la sua intensità"
"symptomDescription": "Uno dei tuoi sintomi, con la sua intensità",
"lifeEventDescription": "Un trasloco, una nascita, un nuovo lavoro",
"lifeEvent": "Evento di vita"
},
"cycle": "Ciclo",
"labs": "Esami",
@@ -578,6 +580,7 @@
"quickAddMood": "Registra umore",
"quickAddMedicationIntake": "Registra assunzione",
"quickAddWorkout": "Registra allenamento",
"quickAddLifeEvent": "Registra evento di vita",
"customizeDashboard": "Personalizza dashboard",
"medicationIntakeQuickAdd": {
"sheetTitle": "Registra assunzione del farmaco",
@@ -12214,8 +12217,8 @@
},
"timeline": {
"title": "Cronologia",
"subtitle": "Anni a colpo d’occhio.",
"legend": "Mostrati uno accanto all’altro, non come causa ed effetto.",
"subtitle": "Cosa è successo e quando, accanto ai tuoi valori.",
"legend": "Uno sotto l’altro significa nello stesso momento, non causa ed effetto.",
"standing": "Permanente",
"startMissing": "Data di inizio mancante",
"openDay": "Apri {date}",
@@ -12254,16 +12257,151 @@
"lanes": {
"values": "Valori",
"mood": "Umore",
"environment": "Ambiente"
"environment": "Ambiente",
"life": "Eventi di vita"
},
"meter": "{carrying} corsie su {total} hanno contenuto",
"carriesDetail": "{carrying} corsie su {total} danno una panoramica da {since}.",
"loadFailed": "Impossibile caricare l’inventario.",
"card": {
"title": "Cosa contiene già la cronologia",
"summary": "Risale fino a {since}; {carrying} corsie su {total} hanno già contenuto.",
"dismiss": "Nascondi"
},
"detail": {
"valuesOne": "{count} misura da {since}",
"valuesFew": "{count} misure da {since}",
"valuesOther": "{count} misure da {since}",
"illnessOne": "{count} episodio; cronici: {chronic}",
"illnessFew": "{count} episodi; cronici: {chronic}",
"illnessOther": "{count} episodi; cronici: {chronic}",
"allergiesOne": "{count} allergia",
"allergiesFew": "{count} allergie",
"allergiesOther": "{count} allergie",
"medicationsOne": "{count} farmaco; senza data di inizio: {missingStart}",
"medicationsFew": "{count} farmaci; senza data di inizio: {missingStart}",
"medicationsOther": "{count} farmaci; senza data di inizio: {missingStart}",
"medicationsDatedOne": "{count} farmaco con data di inizio",
"medicationsDatedFew": "{count} farmaci con data di inizio",
"medicationsDatedOther": "{count} farmaci con data di inizio",
"vaccinationsOne": "{count} vaccinazione",
"vaccinationsFew": "{count} vaccinazioni",
"vaccinationsOther": "{count} vaccinazioni",
"visitsOne": "{count} visita · con intervento: {procedures}",
"visitsFew": "{count} visite · con intervento: {procedures}",
"visitsOther": "{count} visite · con intervento: {procedures}",
"visitsPlannedOne": "{count} visita, solo pianificata",
"visitsPlannedFew": "{count} visite, solo pianificate",
"visitsPlannedOther": "{count} visite, solo pianificate",
"labsOne": "{count} giorno con referti",
"labsFew": "{count} giorni con referti",
"labsOther": "{count} giorni con referti",
"documentsOne": "{count} documento con data",
"documentsFew": "{count} documenti con data",
"documentsOther": "{count} documenti con data",
"lifeOne": "{count} evento di vita",
"lifeFew": "{count} eventi di vita",
"lifeOther": "{count} eventi di vita",
"cycleOne": "{count} ciclo",
"cycleFew": "{count} cicli",
"cycleOther": "{count} cicli",
"moodOne": "{count} voce da {since}",
"moodFew": "{count} voci da {since}",
"moodOther": "{count} voci da {since}",
"environmentOne": "{count} giorno con dati ambientali",
"environmentFew": "{count} giorni con dati ambientali",
"environmentOther": "{count} giorni con dati ambientali",
"empty": "Ancora nulla"
},
"gap": {
"valuesEmpty": "Collega le fonti",
"illnessEmpty": "Aggiungi episodio",
"medicationsWithoutStart": "Completa",
"visitsEmpty": "Aggiungi visita",
"documentsUndated": "Controlla",
"labsEmpty": "Aggiungi referto",
"vaccinationsEmpty": "Aggiungi vaccinazioni",
"lifeEventsEmpty": "Aggiungi"
},
"gapLine": {
"medicationsWithoutStartOne": "{count} farmaco senza data di inizio",
"medicationsWithoutStartFew": "{count} farmaci senza data di inizio",
"medicationsWithoutStartOther": "{count} farmaci senza data di inizio",
"documentsUndatedOne": "{count} documento senza data",
"documentsUndatedFew": "{count} documenti senza data",
"documentsUndatedOther": "{count} documenti senza data",
"valuesEmpty": "Ancora nessun valore su un periodo lungo",
"illnessEmpty": "Ancora nessuna malattia con data di inizio",
"visitsEmpty": "Ancora nessuna visita medica svolta",
"labsEmpty": "Ancora nessun referto di laboratorio",
"vaccinationsEmpty": "Ancora nessuna vaccinazione",
"lifeEventsEmpty": "Ancora nessun evento di vita"
}
}
},
"legendSpan": "Periodo",
"legendEvent": "Evento",
"legendMean": "Media mensile",
"today": "Oggi",
"noDataYet": "Ancora nessun dato",
"layers": "Livelli",
"layersLanes": "Corsie",
"zoomLabel": "Intervallo visualizzato",
"chartLabel": "Cronologia da {from} a {to}",
"keyboardHint": "Le frecce spostano la selezione, Inizio e Fine vanno agli estremi, Invio apre il giorno.",
"loadFailed": "Impossibile caricare la cronologia.",
"empty": {
"title": "Ancora niente nella cronologia",
"description": "Le voci con data compariranno qui appena ce ne saranno."
},
"values": {
"menuLabel": "Fino a tre valori",
"none": "Nessun valore",
"noneInWindow": "Nessun valore in questo periodo",
"series": {
"BLOOD_PRESSURE_SYS": "Pressione sistolica",
"BLOOD_PRESSURE_DIA": "Pressione diastolica"
}
},
"table": {
"caption": "Voci della cronologia",
"lane": "Corsia",
"entry": "Voce",
"start": "Inizio",
"end": "Fine"
},
"selection": {
"empty": "Nessuna voce in questo mese",
"ongoing": "in corso",
"range": "dal {from} al {to}",
"mean": "Ø {values}"
},
"chronicle": {
"months": "Mesi",
"years": "Anni",
"groupingLabel": "Raggruppamento",
"gap": "Da {from} a {to} · nessuna voce",
"gapSingle": "{period} · nessuna voce",
"ended": "Fine: {label}",
"afterDaysOne": "dopo 1 giorno",
"afterDaysFew": "dopo {count} giorni",
"afterDaysOther": "dopo {count} giorni",
"notable": {
"extremeHigh": "Valore più alto da molto tempo",
"extremeLow": "Valore più basso da molto tempo",
"firstValue": "Primo valore di una misura",
"gap": "Una lunga pausa nei valori"
},
"notableMeta": "Apri il giorno per i dettagli"
},
"standingAllergiesOne": "{count} allergia",
"standingAllergiesFew": "{count} allergie",
"standingAllergiesOther": "{count} allergie"
},
"lifeEvents": {
"title": "Evento di vita",
"add": "Evento di vita",
"subtitle": "Un punto di riferimento per la cronologia, non una misurazione.",
"privacy": "Titolo e nota sono salvati cifrati. Non vanno a nessuna IA.",
"empty": "Ancora nessun evento di vita",
"emptyHint": "Un trasloco, una nascita o un nuovo lavoro danno agli anni un punto di riferimento.",
"save": "Salva",
"cancel": "Annulla",
@@ -12279,7 +12417,8 @@
"endDate": "Fino al",
"note": "Nota",
"optionalTag": "facoltativo",
"notePlaceholder": "Qualcosa che vorrai sapere più avanti"
"notePlaceholder": "Qualcosa che vorrai sapere più avanti",
"removeEnd": "Rimuovi data di fine"
},
"category": {
"FAMILY": "Famiglia",
@@ -12292,6 +12431,21 @@
"DAY": "Giorno",
"MONTH": "Mese",
"YEAR": "Anno"
},
"edit": "Modifica evento di vita",
"errors": {
"titleRequired": "Dai un nome all’evento.",
"titleTooLong": "Al massimo 120 caratteri.",
"categoryRequired": "Scegli un tipo.",
"dateRequired": "Inserisci una data valida.",
"endBeforeStart": "La fine è prima dell’inizio.",
"noteTooLong": "Al massimo 2000 caratteri."
},
"saveFailed": "Salvataggio non riuscito.",
"deleteFailed": "Eliminazione non riuscita.",
"deleteConfirm": {
"title": "Eliminare questo evento di vita?",
"body": "Titolo e nota non ci saranno più."
}
}
}