feat: lay down the day view and timeline contract

Land every file the day view, the timeline and life events share before
any of them is built, so the server, the day view and the timeline can be
built side by side without editing the same file (#613).

Schema and migration 0387: LifeEvent with a short category list, calendar
start and end dates, a day/month/year precision, encrypted title and note,
a soft-delete tombstone and an index for the per-user live list. Every new
column is annotated for the column-reader sweep. Life events travel in both
backup purposes with their two ends in one file, are wiped with the
account, and are registered for key rotation.

src/lib/day/contract.ts carries the constants and wire types for
GET /api/day/{date}, /api/day/index, /api/timeline, /api/timeline/readiness
and the life-event routes, free of Zod so the navigation chrome can import
it; the schemas beside it feed the routes and the published contract. The
Coach and MCP tool name get_day is reserved there, and life events are
listed as the one section no model ever sees.

The opt-in timeline module, its navigation entry, the day-section and
timeline-lane surfaces, the day and timeline query keys (evicted by every
record data bundle), and stub routes that answer 501 behind their
admissions. The four cross-section reads declare the whole record for now;
the life-event list is a profile read, and life events join the profile
sharing domain and its consent copy in all seven locales. The day,
timeline and lifeEvents message namespaces carry their base copy as
release-pending keys.

The aggregate bundle cap moves to 4400: the branch already measured 4365
against 4335, and the new copy adds 10 KB.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 13:03:37 +02:00
1 parent 861ba21086
commit c79fc6cc7b
57 files changed
+4290 -21

No files matched your search

+2 -2
View File
@@ -1,5 +1,5 @@
{
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
"routeBaselineKbGz": {
"/page": 429,
"/insights/page": 304,
@@ -8,6 +8,6 @@
"/settings/[section]/page": 403
},
"routeDriftAllowanceKbGz": 20,
"totalClientKbGz": 4335,
"totalClientKbGz": 4400,
"maxRechartsChunks": 1
}
File diff suppressed because it is too large. Load diff
+3
View File
@@ -126,6 +126,9 @@
// a line the moment its first consumer lands; the release integration
// checklist removes whatever is left before the tag.
"src/lib/environment/air-quality-contract.ts",
// The day view and timeline contract (#613): constants and wire types the
// server, the two views and the Coach/MCP tool build against.
"src/lib/day/contract.ts",
],
"ignoreDependencies": [
"@types/node",
+140 -2
View File
@@ -436,7 +436,8 @@
"illness": "Krankheit",
"documents": "Dokumente",
"vaccinations": "Impfungen",
"mentalWellbeing": "Seelisches Wohlbefinden"
"mentalWellbeing": "Seelisches Wohlbefinden",
"timeline": "Zeitleiste"
},
"auth": {
"login": "Anmelden",
@@ -9608,6 +9609,10 @@
"vaccinations": {
"label": "Impfungen",
"description": "Der Impfpass in strukturierter Form: Dosis, Serie, nächste Auffrischung."
},
"timeline": {
"label": "Zeitleiste",
"description": "Jahre auf einen Blick: Erkrankungen, Medikamente und Arztbesuche neben deinen Werten, dazu deine Lebensereignisse."
}
},
"moduleOff": {
@@ -11831,7 +11836,7 @@
},
"profile": {
"label": "Gesundheitliche Vorgeschichte",
"detail": "Allergien, Familienanamnese, die Angaben in deinem Gesundheitsprofil, Arztbesuche und Praxen sowie deine Impfungen."
"detail": "Allergien, Familienanamnese, die Angaben in deinem Gesundheitsprofil, Arztbesuche und Praxen, deine Impfungen sowie deine Lebensereignisse."
},
"illness": {
"label": "Krankheiten",
@@ -12144,5 +12149,138 @@
"title": "Erfasste Symptome",
"filed": "zu dieser Erkrankung erfasst"
}
},
"day": {
"title": "Tag",
"previousDay": "Vortag",
"nextDay": "Folgetag",
"close": "Tag schließen",
"openDay": "Tag öffnen",
"viewWholeDay": "Ganzen Tag ansehen",
"captureForDay": "Für diesen Tag erfassen",
"empty": "Für diesen Tag gibt es keine Einträge.",
"notShared": "Einige Bereiche sind für dich nicht freigegeben.",
"inTimeline": "In der Zeitleiste",
"askCoach": "Im Coach nach diesem Tag fragen",
"chartHint": "Klick auf einen Punkt öffnet den Tag.",
"daysWithEntries": "Tage mit Einträgen",
"loadFailed": "Dieser Tag konnte nicht geladen werden.",
"dayOf": "Tag {n} von {m}",
"dayN": "Tag {n}",
"since": "Seit {date}",
"usualRange": "Üblich {lo} bis {hi}",
"groups": {
"running": "Laufend",
"values": "Werte",
"happened": "Passiert"
},
"sections": {
"values": "Werte",
"sleep": "Schlaf",
"mood": "Stimmung",
"assessments": "Fragebögen",
"medications": "Medikamente",
"illness": "Krankheit",
"symptoms": "Symptome",
"allergies": "Allergien",
"labs": "Labor",
"visits": "Arzt und Eingriffe",
"vaccinations": "Impfungen",
"checkups": "Vorsorge",
"documents": "Dokumente",
"workouts": "Workouts",
"cycle": "Zyklus",
"environment": "Umwelt",
"lifestyle": "Lebensstil",
"lifeEvents": "Lebensereignisse"
},
"notable": {
"extremeHigh": "Höchster Tageswert seit {since}",
"extremeLow": "Niedrigster Tageswert seit {since}",
"firstValue": "Erster Wert dieser Art",
"gap": "{days} Tage ohne Wert"
}
},
"timeline": {
"title": "Zeitleiste",
"subtitle": "Jahre auf einen Blick.",
"legend": "Nebeneinander gezeigt, nicht als Ursache und Wirkung.",
"standing": "Dauerhaft",
"startMissing": "Startdatum fehlt",
"openDay": "{date} öffnen",
"zoom": {
"all": "Alles",
"year": "12 Monate",
"quarter": "3 Monate"
},
"lanes": {
"life": "Leben",
"illness": "Erkrankungen",
"allergies": "Allergien",
"medications": "Medikamente",
"vaccinations": "Impfungen",
"visits": "Arzt und Eingriffe",
"labs": "Labor",
"documents": "Dokumente",
"cycle": "Zyklus"
},
"readiness": {
"title": "Was deine Zeitleiste schon trägt",
"subtitle": "Eine ehrliche Bestandsaufnahme, bevor du sie öffnest.",
"menuEntry": "Datenlage",
"hint": "Leere Spuren bleiben ausgeblendet, bis etwas da ist. Abgeschaltete Module erscheinen nie. Du findest diese Übersicht später unter Datenlage in der Zeitleiste.",
"later": "Später",
"open": "Zeitleiste öffnen",
"verdict": {
"carries": "Trägt schon.",
"thin": "Noch dünn. Die Zeitleiste zeigt vorerst vor allem deine Werte."
},
"status": {
"carries": "trägt",
"thin": "dünn",
"empty": "leer"
},
"lanes": {
"values": "Werte",
"mood": "Stimmung",
"environment": "Umwelt"
}
}
},
"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",
"delete": "Lebensereignis löschen",
"saved": "Lebensereignis gespeichert",
"deleted": "Lebensereignis gelöscht",
"fields": {
"title": "Was war?",
"category": "Art",
"when": "Wann",
"precision": "Genauigkeit",
"addEnd": "Enddatum, wenn es ein Zeitraum war",
"endDate": "Bis",
"note": "Notiz",
"optionalTag": "optional",
"notePlaceholder": "Etwas, das du später wissen willst"
},
"category": {
"FAMILY": "Familie",
"HOME": "Wohnen",
"WORK": "Arbeit",
"LOSS": "Verlust",
"OTHER": "Sonstiges"
},
"precision": {
"DAY": "Tag",
"MONTH": "Monat",
"YEAR": "Jahr"
}
}
}
+140 -2
View File
@@ -436,7 +436,8 @@
"illness": "Illness",
"documents": "Documents",
"vaccinations": "Vaccinations",
"mentalWellbeing": "Mental wellbeing"
"mentalWellbeing": "Mental wellbeing",
"timeline": "Timeline"
},
"auth": {
"login": "Sign in",
@@ -9608,6 +9609,10 @@
"vaccinations": {
"label": "Vaccinations",
"description": "The vaccination record, structured: dose, series, next booster."
},
"timeline": {
"label": "Timeline",
"description": "Years at a glance: conditions, medications and visits beside your readings, plus your own life events."
}
},
"moduleOff": {
@@ -11831,7 +11836,7 @@
},
"profile": {
"label": "Health background",
"detail": "Allergies, family history, the facts in your health profile, your visits and practices, and your vaccination record."
"detail": "Allergies, family history, the facts in your health profile, your visits and practices, your vaccination record and your life events."
},
"illness": {
"label": "Illness",
@@ -12144,5 +12149,138 @@
"title": "Symptoms logged",
"filed": "logged with this illness"
}
},
"day": {
"title": "Day",
"previousDay": "Previous day",
"nextDay": "Next day",
"close": "Close day",
"openDay": "Open day",
"viewWholeDay": "View the whole day",
"captureForDay": "Add for this day",
"empty": "There are no entries for this day.",
"notShared": "Some areas are not shared with you.",
"inTimeline": "In the timeline",
"askCoach": "Ask the Coach about this day",
"chartHint": "Click a point to open its day.",
"daysWithEntries": "Days with entries",
"loadFailed": "This day could not be loaded.",
"dayOf": "Day {n} of {m}",
"dayN": "Day {n}",
"since": "Since {date}",
"usualRange": "Usually {lo} to {hi}",
"groups": {
"running": "Running",
"values": "Values",
"happened": "Happened"
},
"sections": {
"values": "Values",
"sleep": "Sleep",
"mood": "Mood",
"assessments": "Check-ins",
"medications": "Medications",
"illness": "Illness",
"symptoms": "Symptoms",
"allergies": "Allergies",
"labs": "Labs",
"visits": "Doctor and procedures",
"vaccinations": "Vaccinations",
"checkups": "Check-ups",
"documents": "Documents",
"workouts": "Workouts",
"cycle": "Cycle",
"environment": "Environment",
"lifestyle": "Lifestyle",
"lifeEvents": "Life events"
},
"notable": {
"extremeHigh": "Highest daily value since {since}",
"extremeLow": "Lowest daily value since {since}",
"firstValue": "First reading of this kind",
"gap": "No reading for {days} days"
}
},
"timeline": {
"title": "Timeline",
"subtitle": "Years at a glance.",
"legend": "Shown side by side, not as cause and effect.",
"standing": "Ongoing",
"startMissing": "Start date missing",
"openDay": "Open {date}",
"zoom": {
"all": "All",
"year": "12 months",
"quarter": "3 months"
},
"lanes": {
"life": "Life",
"illness": "Conditions",
"allergies": "Allergies",
"medications": "Medications",
"vaccinations": "Vaccinations",
"visits": "Doctor and procedures",
"labs": "Labs",
"documents": "Documents",
"cycle": "Cycle"
},
"readiness": {
"title": "What your timeline already carries",
"subtitle": "An honest inventory before you open it.",
"menuEntry": "Data coverage",
"hint": "Empty lanes stay hidden until something is there. Modules you switched off never appear. You find this overview later under Data coverage in the timeline.",
"later": "Later",
"open": "Open timeline",
"verdict": {
"carries": "It carries already.",
"thin": "Still thin. For now the timeline shows mostly your values."
},
"status": {
"carries": "carries",
"thin": "thin",
"empty": "empty"
},
"lanes": {
"values": "Values",
"mood": "Mood",
"environment": "Environment"
}
}
},
"lifeEvents": {
"title": "Life event",
"add": "Life event",
"subtitle": "An anchor for the timeline, not a reading.",
"privacy": "Title and note are stored encrypted. They go to no AI.",
"empty": "No life events yet",
"emptyHint": "A move, a birth or a new job gives the years an anchor.",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete life event",
"saved": "Life event saved",
"deleted": "Life event deleted",
"fields": {
"title": "What happened?",
"category": "Kind",
"when": "When",
"precision": "Precision",
"addEnd": "End date, if it was a period",
"endDate": "Until",
"note": "Note",
"optionalTag": "optional",
"notePlaceholder": "Something you want to know later"
},
"category": {
"FAMILY": "Family",
"HOME": "Home",
"WORK": "Work",
"LOSS": "Loss",
"OTHER": "Other"
},
"precision": {
"DAY": "Day",
"MONTH": "Month",
"YEAR": "Year"
}
}
}
+140 -2
View File
@@ -436,7 +436,8 @@
"illness": "Enfermedad",
"documents": "Documentos",
"vaccinations": "Vacunas",
"mentalWellbeing": "Bienestar mental"
"mentalWellbeing": "Bienestar mental",
"timeline": "Cronología"
},
"auth": {
"login": "Iniciar sesión",
@@ -9608,6 +9609,10 @@
"vaccinations": {
"label": "Vacunas",
"description": "La cartilla de vacunación estructurada: dosis, serie, próximo refuerzo."
},
"timeline": {
"label": "Cronología",
"description": "Años de un vistazo: enfermedades, medicamentos y visitas junto a tus valores, además de tus propios acontecimientos vitales."
}
},
"moduleOff": {
@@ -11831,7 +11836,7 @@
},
"profile": {
"label": "Antecedentes de salud",
"detail": "Alergias, antecedentes familiares, los datos de tu perfil de salud, tus visitas médicas y consultas, y tu cartilla de vacunación."
"detail": "Alergias, antecedentes familiares, los datos de tu perfil de salud, tus visitas médicas y consultas, tu cartilla de vacunación y tus acontecimientos vitales."
},
"illness": {
"label": "Enfermedades",
@@ -12144,5 +12149,138 @@
"title": "Síntomas registrados",
"filed": "registrado con esta enfermedad"
}
},
"day": {
"title": "Día",
"previousDay": "Día anterior",
"nextDay": "Día siguiente",
"close": "Cerrar día",
"openDay": "Abrir día",
"viewWholeDay": "Ver el día completo",
"captureForDay": "Registrar para este día",
"empty": "No hay entradas para este día.",
"notShared": "Algunas áreas no están compartidas contigo.",
"inTimeline": "En la cronología",
"askCoach": "Preguntar al Coach por este día",
"chartHint": "Haz clic en un punto para abrir su día.",
"daysWithEntries": "Días con entradas",
"loadFailed": "No se pudo cargar este día.",
"dayOf": "Día {n} de {m}",
"dayN": "Día {n}",
"since": "Desde {date}",
"usualRange": "Habitual de {lo} a {hi}",
"groups": {
"running": "En curso",
"values": "Valores",
"happened": "Sucedió"
},
"sections": {
"values": "Valores",
"sleep": "Sueño",
"mood": "Estado de ánimo",
"assessments": "Cuestionarios",
"medications": "Medicamentos",
"illness": "Enfermedad",
"symptoms": "Síntomas",
"allergies": "Alergias",
"labs": "Laboratorio",
"visits": "Médico e intervenciones",
"vaccinations": "Vacunas",
"checkups": "Chequeos",
"documents": "Documentos",
"workouts": "Entrenamientos",
"cycle": "Ciclo",
"environment": "Entorno",
"lifestyle": "Estilo de vida",
"lifeEvents": "Acontecimientos vitales"
},
"notable": {
"extremeHigh": "Valor diario más alto desde {since}",
"extremeLow": "Valor diario más bajo desde {since}",
"firstValue": "Primera medición de este tipo",
"gap": "{days} días sin medición"
}
},
"timeline": {
"title": "Cronología",
"subtitle": "Años de un vistazo.",
"legend": "Mostrado uno junto a otro, no como causa y efecto.",
"standing": "Permanente",
"startMissing": "Falta la fecha de inicio",
"openDay": "Abrir {date}",
"zoom": {
"all": "Todo",
"year": "12 meses",
"quarter": "3 meses"
},
"lanes": {
"life": "Vida",
"illness": "Enfermedades",
"allergies": "Alergias",
"medications": "Medicamentos",
"vaccinations": "Vacunas",
"visits": "Médico e intervenciones",
"labs": "Laboratorio",
"documents": "Documentos",
"cycle": "Ciclo"
},
"readiness": {
"title": "Lo que tu cronología ya muestra",
"subtitle": "Un inventario honesto antes de abrirla.",
"menuEntry": "Cobertura de datos",
"hint": "Las pistas vacías permanecen ocultas hasta que haya algo. Los módulos desactivados nunca aparecen. Encontrarás este resumen más adelante en Cobertura de datos, dentro de la cronología.",
"later": "Más tarde",
"open": "Abrir cronología",
"verdict": {
"carries": "Ya tiene contenido.",
"thin": "Aún escasa. Por ahora la cronología muestra sobre todo tus valores."
},
"status": {
"carries": "completa",
"thin": "escasa",
"empty": "vacía"
},
"lanes": {
"values": "Valores",
"mood": "Estado de ánimo",
"environment": "Entorno"
}
}
},
"lifeEvents": {
"title": "Acontecimiento vital",
"add": "Acontecimiento vital",
"subtitle": "Un ancla para la cronología, no una medición.",
"privacy": "El título y la nota se guardan cifrados. No se envían a ninguna IA.",
"empty": "Aún no hay acontecimientos vitales",
"emptyHint": "Una mudanza, un nacimiento o un nuevo trabajo dan un ancla a los años.",
"save": "Guardar",
"cancel": "Cancelar",
"delete": "Eliminar acontecimiento vital",
"saved": "Acontecimiento vital guardado",
"deleted": "Acontecimiento vital eliminado",
"fields": {
"title": "¿Qué pasó?",
"category": "Tipo",
"when": "Cuándo",
"precision": "Precisión",
"addEnd": "Fecha de fin, si fue un periodo",
"endDate": "Hasta",
"note": "Nota",
"optionalTag": "opcional",
"notePlaceholder": "Algo que quieras saber más adelante"
},
"category": {
"FAMILY": "Familia",
"HOME": "Vivienda",
"WORK": "Trabajo",
"LOSS": "Pérdida",
"OTHER": "Otro"
},
"precision": {
"DAY": "Día",
"MONTH": "Mes",
"YEAR": "Año"
}
}
}
+140 -2
View File
@@ -436,7 +436,8 @@
"illness": "Maladie",
"documents": "Documents",
"vaccinations": "Vaccinations",
"mentalWellbeing": "Bien-être mental"
"mentalWellbeing": "Bien-être mental",
"timeline": "Chronologie"
},
"auth": {
"login": "Se connecter",
@@ -9608,6 +9609,10 @@
"vaccinations": {
"label": "Vaccinations",
"description": "Le carnet de vaccination structuré : dose, série, prochain rappel."
},
"timeline": {
"label": "Chronologie",
"description": "Des années d’un coup d’œil : maladies, médicaments et consultations à côté de vos valeurs, ainsi que vos propres événements de vie."
}
},
"moduleOff": {
@@ -11831,7 +11836,7 @@
},
"profile": {
"label": "Antécédents de santé",
"detail": "Allergies, antécédents familiaux, les informations de votre profil de santé, vos consultations et cabinets, ainsi que votre carnet de vaccination."
"detail": "Allergies, antécédents familiaux, les informations de votre profil de santé, vos consultations et cabinets, votre carnet de vaccination ainsi que vos événements de vie."
},
"illness": {
"label": "Maladies",
@@ -12144,5 +12149,138 @@
"title": "Symptômes notés",
"filed": "noté avec cette maladie"
}
},
"day": {
"title": "Jour",
"previousDay": "Jour précédent",
"nextDay": "Jour suivant",
"close": "Fermer le jour",
"openDay": "Ouvrir le jour",
"viewWholeDay": "Voir toute la journée",
"captureForDay": "Saisir pour ce jour",
"empty": "Aucune entrée pour ce jour.",
"notShared": "Certaines rubriques ne sont pas partagées avec vous.",
"inTimeline": "Dans la chronologie",
"askCoach": "Interroger le Coach sur ce jour",
"chartHint": "Cliquez sur un point pour ouvrir son jour.",
"daysWithEntries": "Jours avec des entrées",
"loadFailed": "Impossible de charger ce jour.",
"dayOf": "Jour {n} sur {m}",
"dayN": "Jour {n}",
"since": "Depuis le {date}",
"usualRange": "Habituellement de {lo} à {hi}",
"groups": {
"running": "En cours",
"values": "Valeurs",
"happened": "Événements"
},
"sections": {
"values": "Valeurs",
"sleep": "Sommeil",
"mood": "Humeur",
"assessments": "Questionnaires",
"medications": "Médicaments",
"illness": "Maladie",
"symptoms": "Symptômes",
"allergies": "Allergies",
"labs": "Analyses",
"visits": "Médecin et interventions",
"vaccinations": "Vaccinations",
"checkups": "Dépistages",
"documents": "Documents",
"workouts": "Entraînements",
"cycle": "Cycle",
"environment": "Environnement",
"lifestyle": "Mode de vie",
"lifeEvents": "Événements de vie"
},
"notable": {
"extremeHigh": "Valeur journalière la plus haute depuis {since}",
"extremeLow": "Valeur journalière la plus basse depuis {since}",
"firstValue": "Première mesure de ce type",
"gap": "{days} jours sans mesure"
}
},
"timeline": {
"title": "Chronologie",
"subtitle": "Des années d’un coup d’œil.",
"legend": "Présentés côte à côte, pas comme cause et effet.",
"standing": "Permanent",
"startMissing": "Date de début manquante",
"openDay": "Ouvrir le {date}",
"zoom": {
"all": "Tout",
"year": "12 mois",
"quarter": "3 mois"
},
"lanes": {
"life": "Vie",
"illness": "Maladies",
"allergies": "Allergies",
"medications": "Médicaments",
"vaccinations": "Vaccinations",
"visits": "Médecin et interventions",
"labs": "Analyses",
"documents": "Documents",
"cycle": "Cycle"
},
"readiness": {
"title": "Ce que votre chronologie contient déjà",
"subtitle": "Un état des lieux honnête avant de l’ouvrir.",
"menuEntry": "Couverture des données",
"hint": "Les pistes vides restent masquées tant qu’elles ne contiennent rien. Les modules désactivés n’apparaissent jamais. Vous retrouverez cet aperçu plus tard sous Couverture des données, dans la chronologie.",
"later": "Plus tard",
"open": "Ouvrir la chronologie",
"verdict": {
"carries": "Elle est déjà bien remplie.",
"thin": "Encore maigre. Pour l’instant, la chronologie montre surtout vos valeurs."
},
"status": {
"carries": "remplie",
"thin": "maigre",
"empty": "vide"
},
"lanes": {
"values": "Valeurs",
"mood": "Humeur",
"environment": "Environnement"
}
}
},
"lifeEvents": {
"title": "Événement de vie",
"add": "Événement de vie",
"subtitle": "Un repère pour la chronologie, pas une mesure.",
"privacy": "Le titre et la note sont chiffrés. Ils ne sont envoyés à aucune IA.",
"empty": "Aucun événement de vie pour l’instant",
"emptyHint": "Un déménagement, une naissance ou un nouvel emploi donnent un repère aux années.",
"save": "Enregistrer",
"cancel": "Annuler",
"delete": "Supprimer l’événement de vie",
"saved": "Événement de vie enregistré",
"deleted": "Événement de vie supprimé",
"fields": {
"title": "Que s’est-il passé ?",
"category": "Type",
"when": "Quand",
"precision": "Précision",
"addEnd": "Date de fin, s’il s’agissait d’une période",
"endDate": "Jusqu’au",
"note": "Note",
"optionalTag": "facultatif",
"notePlaceholder": "Quelque chose que vous voudrez savoir plus tard"
},
"category": {
"FAMILY": "Famille",
"HOME": "Logement",
"WORK": "Travail",
"LOSS": "Deuil",
"OTHER": "Autre"
},
"precision": {
"DAY": "Jour",
"MONTH": "Mois",
"YEAR": "Année"
}
}
}
+140 -2
View File
@@ -436,7 +436,8 @@
"illness": "Malattia",
"documents": "Documenti",
"vaccinations": "Vaccinazioni",
"mentalWellbeing": "Benessere mentale"
"mentalWellbeing": "Benessere mentale",
"timeline": "Cronologia"
},
"auth": {
"login": "Accedi",
@@ -9608,6 +9609,10 @@
"vaccinations": {
"label": "Vaccinazioni",
"description": "Il libretto vaccinale strutturato: dose, serie, prossimo richiamo."
},
"timeline": {
"label": "Cronologia",
"description": "Anni a colpo d’occhio: malattie, farmaci e visite accanto ai tuoi valori, più i tuoi eventi di vita."
}
},
"moduleOff": {
@@ -11831,7 +11836,7 @@
},
"profile": {
"label": "Anamnesi",
"detail": "Allergie, anamnesi familiare, i dati del tuo profilo sanitario, le tue visite mediche e studi, e il tuo libretto vaccinale."
"detail": "Allergie, anamnesi familiare, i dati del tuo profilo sanitario, le tue visite mediche e studi, il tuo libretto vaccinale e i tuoi eventi di vita."
},
"illness": {
"label": "Malattie",
@@ -12144,5 +12149,138 @@
"title": "Sintomi registrati",
"filed": "registrato con questa malattia"
}
},
"day": {
"title": "Giorno",
"previousDay": "Giorno precedente",
"nextDay": "Giorno successivo",
"close": "Chiudi giorno",
"openDay": "Apri giorno",
"viewWholeDay": "Vedi l’intera giornata",
"captureForDay": "Registra per questo giorno",
"empty": "Non ci sono voci per questo giorno.",
"notShared": "Alcune aree non sono condivise con te.",
"inTimeline": "Nella cronologia",
"askCoach": "Chiedi al Coach di questo giorno",
"chartHint": "Fai clic su un punto per aprire il suo giorno.",
"daysWithEntries": "Giorni con voci",
"loadFailed": "Impossibile caricare questo giorno.",
"dayOf": "Giorno {n} di {m}",
"dayN": "Giorno {n}",
"since": "Dal {date}",
"usualRange": "Di solito da {lo} a {hi}",
"groups": {
"running": "In corso",
"values": "Valori",
"happened": "Accaduto"
},
"sections": {
"values": "Valori",
"sleep": "Sonno",
"mood": "Umore",
"assessments": "Questionari",
"medications": "Farmaci",
"illness": "Malattia",
"symptoms": "Sintomi",
"allergies": "Allergie",
"labs": "Esami",
"visits": "Medico e interventi",
"vaccinations": "Vaccinazioni",
"checkups": "Controlli",
"documents": "Documenti",
"workouts": "Allenamenti",
"cycle": "Ciclo",
"environment": "Ambiente",
"lifestyle": "Stile di vita",
"lifeEvents": "Eventi di vita"
},
"notable": {
"extremeHigh": "Valore giornaliero più alto dal {since}",
"extremeLow": "Valore giornaliero più basso dal {since}",
"firstValue": "Prima misurazione di questo tipo",
"gap": "{days} giorni senza misurazione"
}
},
"timeline": {
"title": "Cronologia",
"subtitle": "Anni a colpo d’occhio.",
"legend": "Mostrati uno accanto all’altro, non come causa ed effetto.",
"standing": "Permanente",
"startMissing": "Data di inizio mancante",
"openDay": "Apri {date}",
"zoom": {
"all": "Tutto",
"year": "12 mesi",
"quarter": "3 mesi"
},
"lanes": {
"life": "Vita",
"illness": "Malattie",
"allergies": "Allergie",
"medications": "Farmaci",
"vaccinations": "Vaccinazioni",
"visits": "Medico e interventi",
"labs": "Esami",
"documents": "Documenti",
"cycle": "Ciclo"
},
"readiness": {
"title": "Cosa contiene già la tua cronologia",
"subtitle": "Un inventario onesto prima di aprirla.",
"menuEntry": "Copertura dei dati",
"hint": "Le corsie vuote restano nascoste finché non c’è qualcosa. I moduli disattivati non compaiono mai. Ritroverai questa panoramica più avanti in Copertura dei dati, nella cronologia.",
"later": "Più tardi",
"open": "Apri cronologia",
"verdict": {
"carries": "È già ben fornita.",
"thin": "Ancora scarna. Per ora la cronologia mostra soprattutto i tuoi valori."
},
"status": {
"carries": "fornita",
"thin": "scarna",
"empty": "vuota"
},
"lanes": {
"values": "Valori",
"mood": "Umore",
"environment": "Ambiente"
}
}
},
"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",
"delete": "Elimina evento di vita",
"saved": "Evento di vita salvato",
"deleted": "Evento di vita eliminato",
"fields": {
"title": "Cosa è successo?",
"category": "Tipo",
"when": "Quando",
"precision": "Precisione",
"addEnd": "Data di fine, se è stato un periodo",
"endDate": "Fino al",
"note": "Nota",
"optionalTag": "facoltativo",
"notePlaceholder": "Qualcosa che vorrai sapere più avanti"
},
"category": {
"FAMILY": "Famiglia",
"HOME": "Casa",
"WORK": "Lavoro",
"LOSS": "Lutto",
"OTHER": "Altro"
},
"precision": {
"DAY": "Giorno",
"MONTH": "Mese",
"YEAR": "Anno"
}
}
}
+140 -2
View File
@@ -436,7 +436,8 @@
"illness": "아픈 날",
"documents": "문서",
"vaccinations": "예방접종",
"mentalWellbeing": "정신 건강"
"mentalWellbeing": "정신 건강",
"timeline": "타임라인"
},
"auth": {
"login": "로그인",
@@ -9608,6 +9609,10 @@
"vaccinations": {
"label": "예방접종",
"description": "예방접종 기록을 구조화해서: 용량, 차수, 다음 추가 접종."
},
"timeline": {
"label": "타임라인",
"description": "몇 년을 한눈에: 수치 옆에 질환, 약, 진료가 놓이고, 내 삶의 사건도 함께 보여요."
}
},
"moduleOff": {
@@ -11831,7 +11836,7 @@
},
"profile": {
"label": "건강 배경",
"detail": "알레르기, 가족력, 건강 프로필의 항목들, 진료와 병원, 예방접종 기록이에요."
"detail": "알레르기, 가족력, 건강 프로필의 항목들, 진료와 병원, 예방접종 기록, 삶의 사건이에요."
},
"illness": {
"label": "아픈 날",
@@ -12144,5 +12149,138 @@
"title": "기록된 증상",
"filed": "이 질병과 함께 기록됨"
}
},
"day": {
"title": "하루",
"previousDay": "전날",
"nextDay": "다음 날",
"close": "하루 닫기",
"openDay": "하루 열기",
"viewWholeDay": "하루 전체 보기",
"captureForDay": "이 날에 기록하기",
"empty": "이 날에는 기록이 없어요.",
"notShared": "일부 영역은 공유되지 않았어요.",
"inTimeline": "타임라인에서 보기",
"askCoach": "이 날에 대해 코치에게 묻기",
"chartHint": "점을 클릭하면 그날이 열려요.",
"daysWithEntries": "기록이 있는 날",
"loadFailed": "이 날을 불러오지 못했어요.",
"dayOf": "{m}일 중 {n}일째",
"dayN": "{n}일째",
"since": "{date}부터",
"usualRange": "평소 {lo}~{hi}",
"groups": {
"running": "진행 중",
"values": "수치",
"happened": "있었던 일"
},
"sections": {
"values": "수치",
"sleep": "수면",
"mood": "기분",
"assessments": "설문",
"medications": "약",
"illness": "질환",
"symptoms": "증상",
"allergies": "알레르기",
"labs": "검사",
"visits": "진료와 시술",
"vaccinations": "예방접종",
"checkups": "건강검진",
"documents": "문서",
"workouts": "운동",
"cycle": "생리 주기",
"environment": "환경",
"lifestyle": "생활 습관",
"lifeEvents": "삶의 사건"
},
"notable": {
"extremeHigh": "{since} 이후 가장 높은 하루 값",
"extremeLow": "{since} 이후 가장 낮은 하루 값",
"firstValue": "이 종류의 첫 측정",
"gap": "{days}일 동안 측정 없음"
}
},
"timeline": {
"title": "타임라인",
"subtitle": "몇 년을 한눈에.",
"legend": "나란히 보여줄 뿐, 원인과 결과를 뜻하지 않아요.",
"standing": "지속",
"startMissing": "시작일 없음",
"openDay": "{date} 열기",
"zoom": {
"all": "전체",
"year": "12개월",
"quarter": "3개월"
},
"lanes": {
"life": "삶",
"illness": "질환",
"allergies": "알레르기",
"medications": "약",
"vaccinations": "예방접종",
"visits": "진료와 시술",
"labs": "검사",
"documents": "문서",
"cycle": "생리 주기"
},
"readiness": {
"title": "타임라인에 이미 담긴 것",
"subtitle": "열기 전에 솔직하게 살펴보는 현황이에요.",
"menuEntry": "데이터 현황",
"hint": "빈 줄은 무언가 생길 때까지 숨겨져요. 꺼 둔 모듈은 나타나지 않아요. 이 현황은 나중에 타임라인의 데이터 현황에서 다시 볼 수 있어요.",
"later": "나중에",
"open": "타임라인 열기",
"verdict": {
"carries": "이미 충분히 채워졌어요.",
"thin": "아직 부족해요. 당분간 타임라인은 주로 수치를 보여줘요."
},
"status": {
"carries": "충분",
"thin": "부족",
"empty": "비어 있음"
},
"lanes": {
"values": "수치",
"mood": "기분",
"environment": "환경"
}
}
},
"lifeEvents": {
"title": "삶의 사건",
"add": "삶의 사건",
"subtitle": "측정값이 아니라 타임라인의 기준점이에요.",
"privacy": "제목과 메모는 암호화되어 저장돼요. 어떤 AI에도 보내지 않아요.",
"empty": "아직 삶의 사건이 없어요",
"emptyHint": "이사, 출산, 새 직장 같은 일이 몇 년에 기준점을 만들어 줘요.",
"save": "저장",
"cancel": "취소",
"delete": "삶의 사건 삭제",
"saved": "삶의 사건을 저장했어요",
"deleted": "삶의 사건을 삭제했어요",
"fields": {
"title": "무슨 일이 있었나요?",
"category": "종류",
"when": "언제",
"precision": "정확도",
"addEnd": "기간이었다면 종료일",
"endDate": "종료",
"note": "메모",
"optionalTag": "선택",
"notePlaceholder": "나중에 알고 싶은 내용"
},
"category": {
"FAMILY": "가족",
"HOME": "주거",
"WORK": "일",
"LOSS": "상실",
"OTHER": "기타"
},
"precision": {
"DAY": "일",
"MONTH": "월",
"YEAR": "연"
}
}
}
+140 -2
View File
@@ -436,7 +436,8 @@
"illness": "Choroba",
"documents": "Dokumenty",
"vaccinations": "Szczepienia",
"mentalWellbeing": "Dobrostan psychiczny"
"mentalWellbeing": "Dobrostan psychiczny",
"timeline": "Oś czasu"
},
"auth": {
"login": "Zaloguj się",
@@ -9608,6 +9609,10 @@
"vaccinations": {
"label": "Szczepienia",
"description": "Książeczka szczepień uporządkowana: dawka, seria, następne przypomnienie."
},
"timeline": {
"label": "Oś czasu",
"description": "Lata w jednym widoku: choroby, leki i wizyty obok Twoich wartości, a do tego Twoje własne wydarzenia życiowe."
}
},
"moduleOff": {
@@ -11831,7 +11836,7 @@
},
"profile": {
"label": "Historia zdrowia",
"detail": "Alergie, wywiad rodzinny, dane z Twojego profilu zdrowotnego, wizyty lekarskie i gabinety oraz Twoja historia szczepień."
"detail": "Alergie, wywiad rodzinny, dane z Twojego profilu zdrowotnego, wizyty lekarskie i gabinety, Twoja historia szczepień oraz Twoje wydarzenia życiowe."
},
"illness": {
"label": "Choroby",
@@ -12144,5 +12149,138 @@
"title": "Zapisane objawy",
"filed": "zapisano przy tej chorobie"
}
},
"day": {
"title": "Dzień",
"previousDay": "Poprzedni dzień",
"nextDay": "Następny dzień",
"close": "Zamknij dzień",
"openDay": "Otwórz dzień",
"viewWholeDay": "Zobacz cały dzień",
"captureForDay": "Dodaj dla tego dnia",
"empty": "Brak wpisów dla tego dnia.",
"notShared": "Niektóre obszary nie są Ci udostępnione.",
"inTimeline": "Na osi czasu",
"askCoach": "Zapytaj Coacha o ten dzień",
"chartHint": "Kliknij punkt, aby otworzyć jego dzień.",
"daysWithEntries": "Dni z wpisami",
"loadFailed": "Nie udało się wczytać tego dnia.",
"dayOf": "Dzień {n} z {m}",
"dayN": "Dzień {n}",
"since": "Od {date}",
"usualRange": "Zwykle od {lo} do {hi}",
"groups": {
"running": "W toku",
"values": "Wartości",
"happened": "Wydarzyło się"
},
"sections": {
"values": "Wartości",
"sleep": "Sen",
"mood": "Nastrój",
"assessments": "Kwestionariusze",
"medications": "Leki",
"illness": "Choroba",
"symptoms": "Objawy",
"allergies": "Alergie",
"labs": "Badania",
"visits": "Lekarz i zabiegi",
"vaccinations": "Szczepienia",
"checkups": "Badania profilaktyczne",
"documents": "Dokumenty",
"workouts": "Treningi",
"cycle": "Cykl",
"environment": "Otoczenie",
"lifestyle": "Styl życia",
"lifeEvents": "Wydarzenia życiowe"
},
"notable": {
"extremeHigh": "Najwyższa wartość dzienna od {since}",
"extremeLow": "Najniższa wartość dzienna od {since}",
"firstValue": "Pierwszy pomiar tego rodzaju",
"gap": "{days} dni bez pomiaru"
}
},
"timeline": {
"title": "Oś czasu",
"subtitle": "Lata w jednym widoku.",
"legend": "Pokazane obok siebie, nie jako przyczyna i skutek.",
"standing": "Stałe",
"startMissing": "Brak daty rozpoczęcia",
"openDay": "Otwórz {date}",
"zoom": {
"all": "Wszystko",
"year": "12 miesięcy",
"quarter": "3 miesiące"
},
"lanes": {
"life": "Życie",
"illness": "Choroby",
"allergies": "Alergie",
"medications": "Leki",
"vaccinations": "Szczepienia",
"visits": "Lekarz i zabiegi",
"labs": "Badania",
"documents": "Dokumenty",
"cycle": "Cykl"
},
"readiness": {
"title": "Co już zawiera Twoja oś czasu",
"subtitle": "Uczciwy przegląd, zanim ją otworzysz.",
"menuEntry": "Stan danych",
"hint": "Puste ścieżki pozostają ukryte, dopóki coś się nie pojawi. Wyłączone moduły nigdy się nie pojawiają. Ten przegląd znajdziesz później w sekcji Stan danych na osi czasu.",
"later": "Później",
"open": "Otwórz oś czasu",
"verdict": {
"carries": "Już ma treść.",
"thin": "Jeszcze skromna. Na razie oś czasu pokazuje głównie Twoje wartości."
},
"status": {
"carries": "wypełniona",
"thin": "skromna",
"empty": "pusta"
},
"lanes": {
"values": "Wartości",
"mood": "Nastrój",
"environment": "Otoczenie"
}
}
},
"lifeEvents": {
"title": "Wydarzenie życiowe",
"add": "Wydarzenie życiowe",
"subtitle": "Punkt odniesienia na osi czasu, nie pomiar.",
"privacy": "Tytuł i notatka są zapisywane w postaci zaszyfrowanej. Nie trafiają do żadnej AI.",
"empty": "Brak wydarzeń życiowych",
"emptyHint": "Przeprowadzka, narodziny dziecka czy nowa praca dają latom punkt odniesienia.",
"save": "Zapisz",
"cancel": "Anuluj",
"delete": "Usuń wydarzenie życiowe",
"saved": "Zapisano wydarzenie życiowe",
"deleted": "Usunięto wydarzenie życiowe",
"fields": {
"title": "Co się wydarzyło?",
"category": "Rodzaj",
"when": "Kiedy",
"precision": "Dokładność",
"addEnd": "Data zakończenia, jeśli był to okres",
"endDate": "Do",
"note": "Notatka",
"optionalTag": "opcjonalnie",
"notePlaceholder": "Coś, co zechcesz wiedzieć później"
},
"category": {
"FAMILY": "Rodzina",
"HOME": "Mieszkanie",
"WORK": "Praca",
"LOSS": "Strata",
"OTHER": "Inne"
},
"precision": {
"DAY": "Dzień",
"MONTH": "Miesiąc",
"YEAR": "Rok"
}
}
}
@@ -0,0 +1,31 @@
-- Life events for the timeline (#613).
--
-- A dated anchor in the person's life: a move, a birth, a new job, a loss.
-- Dates are calendar strings (`YYYY-MM-DD`) without a time zone, and
-- `precision` says how much of the date is known; at MONTH or YEAR the stored
-- date is the first day of that month or year. Title and note are encrypted
-- at rest. Soft-deleted like the other record tables; the index serves the
-- per-user live list in date order.
CREATE TYPE "life_event_category" AS ENUM ('FAMILY', 'HOME', 'WORK', 'LOSS', 'OTHER');
CREATE TYPE "life_event_precision" AS ENUM ('DAY', 'MONTH', 'YEAR');
CREATE TABLE "life_events" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"category" "life_event_category" NOT NULL,
"start_date" TEXT NOT NULL,
"end_date" TEXT,
"precision" "life_event_precision" NOT NULL,
"title_encrypted" BYTEA NOT NULL,
"note_encrypted" BYTEA,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"deleted_at" TIMESTAMP(3),
CONSTRAINT "life_events_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "life_events_user_id_deleted_at_start_date_idx" ON "life_events"("user_id", "deleted_at", "start_date");
ALTER TABLE "life_events" ADD CONSTRAINT "life_events_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+69
View File
@@ -992,6 +992,10 @@ model User {
// over, and the guardian who minted the link.
handoversOfProfile ManagedProfileHandover[] @relation("HandoverProfile")
handoversCreated ManagedProfileHandover[] @relation("HandoverCreator")
// v1.42 — dated anchors in the person's life for the timeline (#613): a
// move, a birth, a new job. Shared under the `profile` domain; never sent to
// a model.
lifeEvents LifeEvent[]
// v1.7.0 profile — optional patient-identity fields surfaced on the
// health-record export cover (PDF) and the FHIR `Patient` resource.
@@ -9563,3 +9567,68 @@ model DocumentAiRun {
@@index([status, expiresAt])
@@map("document_ai_runs")
}
/// A dated anchor in the person's life for the timeline (v1.42, #613): a move,
/// a birth, a new job, a loss. Not a health reading and not a symptom; it gives
/// the years around the readings something to hang on.
///
/// Shared under the `profile` domain, beside allergies, visits and the
/// immunization history. Never sent to a model in any form: the Coach, the
/// MCP endpoint and every AI job leave it out, and the day loader's model-facing
/// projection drops it.
///
/// Dates are calendar dates without a time zone (`YYYY-MM-DD`), the
/// `src/lib/tz/date-only.ts` convention, because "we moved in September 2023"
/// names a day on a calendar, not an instant. `precision` says how much of the
/// date the person actually knows; at MONTH or YEAR the stored date is the
/// first day of that month or year, and only a DAY event appears in a day view.
model LifeEvent {
id String @id @default(cuid())
userId String @map("user_id")
/// @pending-consumer(#613) The kind of event, from a deliberately short list.
category LifeEventCategory
/// @pending-consumer(#613) First day of the event, `YYYY-MM-DD`. At MONTH or
/// YEAR precision the first day of that month or year.
startDate String @map("start_date")
/// @pending-consumer(#613) Last day, `YYYY-MM-DD`, for an event that spans a
/// period (parental leave, a year abroad). NULL = a single date.
endDate String? @map("end_date")
/// @pending-consumer(#613) How much of the date is known. Applies to both
/// ends.
precision LifeEventPrecision
/// @pending-consumer(#613) What happened, in the person's own words,
/// AES-256-GCM encrypted at rest.
titleEncrypted Bytes @map("title_encrypted")
/// @pending-consumer(#613) An optional note, AES-256-GCM encrypted at rest.
noteEncrypted Bytes? @map("note_encrypted")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
/// Soft-delete tombstone. NULL = live.
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, deletedAt, startDate])
@@map("life_events")
}
/// What kind of life event. Few on purpose: travel already comes from the
/// environment module, and a long list turns an anchor into a diary.
enum LifeEventCategory {
FAMILY
HOME
WORK
LOSS
OTHER
@@map("life_event_category")
}
/// How much of a life event's date the person knows.
enum LifeEventPrecision {
DAY
MONTH
YEAR
@@map("life_event_precision")
}
+8
View File
@@ -662,6 +662,14 @@ async function main() {
prisma.symptomEvent,
),
);
// v1.42 — a life event's title and note. The note is NULL on most rows,
// which the walk skips.
results.push(
await rotateBytesColumn("LifeEvent", "titleEncrypted", prisma.lifeEvent),
);
results.push(
await rotateBytesColumn("LifeEvent", "noteEncrypted", prisma.lifeEvent),
);
// ───── v1.19.0 ECG waveform (Bytes column) ─────
// "waveformEncrypted" holds the JSON-encoded micro-volt sample array in the
@@ -80,6 +80,9 @@ const DYNAMIC_ALLOWLIST_PREFIXES = [
const RELEASE_PENDING_PREFIXES: Record<string, string> = {
"vaccinations.custom": "v1.42 user-defined vaccines (#1005)",
"labs.convertedFrom": "v1.42 lab unit conversion note (#1095)",
day: "v1.42 day view and its entry points (#613)",
timeline: "v1.42 timeline and its readiness inventory (#613)",
lifeEvents: "v1.42 life-event form and list (#613)",
};
/**
+32 -1
View File
@@ -1007,6 +1007,31 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "illness",
why: "One symptom occurrence of the record, fetch-then-guard against the resolved user. Reached only through this module's MANAGE arm.",
},
// v1.42 (#613) — the day, the day index, the timeline and its readiness
// are reads across every section, so they declare the whole record, the
// posture of the dashboard summary: a full-record grant reads them, a
// scoped grant never does. Narrowing them to the grant's own sections is a
// change of admission, not of this line, and comes back here for review.
"app/api/day/[date]/route.ts": {
domain: "record",
why: "One local day across the record: what ran through it, the readings in its window, what happened on it. Read-only, every row scoped to the resolved user, decrypted only for the session that resolved it. Life events ride along under `profile`, which a full-record grant covers.",
},
"app/api/day/index/route.ts": {
domain: "record",
why: "Which days of a bounded window hold anything, by section key. Dates and section names only, no values and no free text; every read scoped to the resolved user.",
},
"app/api/timeline/route.ts": {
domain: "record",
why: "The record over the years: spans and points from every section plus monthly means. Read-only, scoped to the resolved user, module-gated on the record's own `timeline` switch.",
},
"app/api/timeline/readiness/route.ts": {
domain: "record",
why: "Counts per timeline lane with an in-app link per gap. Counts only, no record content; scoped to the resolved user and module-gated like the timeline.",
},
"app/api/life-events/route.ts": {
domain: "profile",
why: "The record's life events, a section of health background beside allergies and visits; the consent copy for `profile` names them. The GET reads rows scoped `userId: user.id` and decrypts title and note for the resolved session. The create beside it keeps `requireAuth()` until it files an audit row and joins the write literal.",
},
"app/api/workouts/route.ts": {
domain: "measurements",
why: "The record's workout list. Verified to carry no AI-written paragraph: the single-workout route beside it does, and stays refused for exactly that reason.",
@@ -2010,8 +2035,14 @@ const ACTOR_ROUTES: Record<string, string> = {
* list, the occurrence create on the write literal, and the definition create,
* the definition edit/delete and the occurrence edit/delete on the manage
* literal. 232 -> 242 with both.
*
* v1.42 -- the day view and the timeline add five reads on the record list:
* the day, the day index, the timeline, its readiness inventory (all four
* across the whole record) and the life-event list in `profile`. The life-event
* create, edit and delete stay owner-only until they carry their audit rows.
* 242 -> 247.
*/
const FROZEN_ENTRY_COUNT = 242;
const FROZEN_ENTRY_COUNT = 247;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -193,6 +193,11 @@ const MODULE_ROUTE_TREES_BY_KEY: Readonly<
// data-layer-gated: every `/api/vaccinations/*` route is raw CRUD over the
// person's own doses, so all six are EXEMPT below under the same reasoning.
vaccinations: ["src/app/api/vaccinations"],
// v1.42 (#613) — the life timeline. The timeline reads and the readiness
// inventory gate on `timeline`; the life-event row store beside them is
// EXEMPT below under the data-layer reasoning. The day view is not this
// module's: it is core and lives under `/api/day`, which no key owns.
timeline: ["src/app/api/timeline", "src/app/api/life-events"],
};
/** Every declared tree, de-duplicated — the set this inventory walks. */
@@ -413,6 +418,14 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
// while the nav entry, the picker and the report leaf hide.
"src/app/api/vaccinations/[id]/booster/route.ts",
"src/app/api/vaccinations/suggest/route.ts",
// ── DATA LAYER (timeline) ─────────────────────────────────────────
// v1.42 (#613) — the record's life events. Raw CRUD over the person's own
// rows: the module decides whether they SURFACE (the timeline, the day's
// life-event line, the capture entry), not whether the store accepts them.
// A restore must bring them back with the module off, and re-enabling must
// find every event where it was.
"src/app/api/life-events/route.ts",
"src/app/api/life-events/[id]/route.ts",
// ── INFRA / CREDENTIALS (mcp) ─────────────────────────────────────
// The remote MCP endpoint itself is `src/app/mcp/route.ts`, outside `/api`,
// and it carries the gate: an account with the module off gets a 404 there
+22
View File
@@ -0,0 +1,22 @@
/**
* `GET /api/day/{date}` — one local day across the record (v1.42, #613): what
* ran through it, the readings in its window, what happened on it, and the
* few deterministic observations worth a second look. The shape is
* `DayResponse` in `src/lib/day/contract.ts`.
*
* Core, not a module: the day opens for every account. Sections of a
* switched-off module are left out; a section the caller's grant does not
* cover is named in `sections` and not read.
*
* Contract stub: answers 501 behind its admission. It declares the whole
* record for now, the posture every cross-section read in the tree has; a
* scoped grant is refused until the loader narrows each section to the
* grant's domains and the admission is reviewed for that.
*/
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
export const GET = apiHandler(async () => {
await requireRecordAuth("read", "record");
return notImplemented();
});
+16
View File
@@ -0,0 +1,16 @@
/**
* `GET /api/day/index?from=&to=` — which days in a window hold anything, and
* which of them carry a notable observation (v1.42, #613). Feeds the row of
* day dots under a chart and the day links in lists; at most
* `DAY_INDEX_MAX_SPAN_DAYS` days per call. The shape is `DayIndexResponse` in
* `src/lib/day/contract.ts`.
*
* Contract stub: answers 501 behind the same admission as the day itself.
*/
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
export const GET = apiHandler(async () => {
await requireRecordAuth("read", "record");
return notImplemented();
});
@@ -75,6 +75,7 @@ vi.mock("@/lib/db", () => ({
extractedFact: { findMany: vi.fn().mockResolvedValue([]) },
ecgRecording: { findMany: vi.fn().mockResolvedValue([]) },
symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) },
lifeEvent: { findMany: vi.fn().mockResolvedValue([]) },
// v1.15.0 — cycle tables read by the full-backup helper.
cycleProfile: { findUnique: vi.fn() },
menstrualCycle: { findMany: vi.fn() },
@@ -171,6 +172,7 @@ beforeEach(() => {
vi.mocked(prisma.coachReminder.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.ecgRecording.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.symptomDefinition.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.lifeEvent.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodTagCategory.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodTagHidden.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.mentalHealthAssessment.findMany).mockResolvedValue(
@@ -76,6 +76,7 @@ vi.mock("@/lib/db", () => ({
extractedFact: { findMany: vi.fn() },
ecgRecording: { findMany: vi.fn() },
symptomDefinition: { findMany: vi.fn() },
lifeEvent: { findMany: vi.fn() },
onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) },
environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) },
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
@@ -213,6 +214,7 @@ beforeEach(() => {
vi.mocked(prisma.extractedFact.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.ecgRecording.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.symptomDefinition.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.lifeEvent.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userAchievement.findMany).mockResolvedValue([] as never);
});
@@ -275,6 +277,12 @@ describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
where: { userId: "user-1", deletedAt: null },
}),
);
// v1.42 — a deleted life event stays out of a portable file.
expect(prisma.lifeEvent.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "user-1", deletedAt: null },
}),
);
// Biomarker + Workout carry no soft-delete tombstone — userId-only scope.
expect(prisma.biomarker.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { userId: "user-1" } }),
@@ -67,6 +67,7 @@ vi.mock("@/lib/db", () => ({
extractedFact: { findMany: vi.fn().mockResolvedValue([]) },
ecgRecording: { findMany: vi.fn().mockResolvedValue([]) },
symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) },
lifeEvent: { findMany: vi.fn().mockResolvedValue([]) },
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
+21
View File
@@ -0,0 +1,21 @@
/**
* `PATCH /api/life-events/{id}` edits one life event; `DELETE` soft-deletes
* it (v1.42, #613). Record-scoped like the list beside it.
*
* Contract stub: owner-only until implemented, then answers 501. The
* finished routes resolve the record through `requireRecordAuth` and join
* the sharing guard's delegable lists with their audit rows; a stub cannot
* carry those, so it does not claim the admission.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
export const PATCH = apiHandler(async () => {
await requireAuth();
return notImplemented();
});
export const DELETE = apiHandler(async () => {
await requireAuth();
return notImplemented();
});
+26
View File
@@ -0,0 +1,26 @@
/**
* `GET /api/life-events` lists the record's life events, oldest first;
* `POST` adds one (v1.42, #613). Title and note are encrypted at rest and
* never reach a model. Shapes and request schemas in `src/lib/day/`.
*
* Part of the `profile` sharing domain, beside allergies, visits and the
* immunization history. The row store answers whether or not the `timeline`
* module is on, the data-layer posture of the other record tables, so a
* restore keeps working and re-enabling finds the events intact.
*
* Contract stub: answers 501. The read declares its final admission; the
* create stays owner-only until it files its rows through the audit trail
* and joins the delegable write list.
*/
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
export const GET = apiHandler(async () => {
await requireRecordAuth("read", "profile");
return notImplemented();
});
export const POST = apiHandler(async () => {
await requireAuth();
return notImplemented();
});
+18
View File
@@ -0,0 +1,18 @@
/**
* `GET /api/timeline/readiness` — what the timeline can already show, lane by
* lane, with one link per gap (v1.42, #613). Counts and keys, never a score.
* The shape is `TimelineReadinessResponse` in `src/lib/day/contract.ts`.
*
* Gated on the opt-in `timeline` module. Contract stub: answers 501 behind
* its admission and the gate.
*/
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { requireModuleEnabled } from "@/lib/modules/gate";
export const GET = apiHandler(async () => {
const { user } = await requireRecordAuth("read", "record");
const gate = await requireModuleEnabled(user.id, "timeline");
if (!gate.enabled) return gate.response;
return notImplemented();
});
+20
View File
@@ -0,0 +1,20 @@
/**
* `GET /api/timeline?zoom=&from=&to=&values=` — the record laid out over the
* years (v1.42, #613): lanes of conditions, medications, visits, documents and
* life events, the standing items without a start, and monthly value series.
* The shape is `TimelineResponse` in `src/lib/day/contract.ts`.
*
* Gated on the opt-in `timeline` module. Contract stub: answers 501 behind
* its admission and the gate. Like the day, it declares the whole record
* until each lane is narrowed to the grant's domains.
*/
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { requireModuleEnabled } from "@/lib/modules/gate";
export const GET = apiHandler(async () => {
const { user } = await requireRecordAuth("read", "record");
const gate = await requireModuleEnabled(user.id, "timeline");
if (!gate.enabled) return gate.response;
return notImplemented();
});
+13
View File
@@ -6,6 +6,7 @@ import {
Droplets,
FileScan,
FlaskConical,
History,
Home,
Lightbulb,
MessagesSquare,
@@ -206,6 +207,18 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
icon: FileScan,
tourId: "nav-documents",
},
// v1.42 (#613) — the life timeline, after Documents and before Insights.
// Owned by the opt-in `timeline` module in the surface map, so it appears
// only once the person switches it on; on a phone it lands in the More hub
// and takes no primary slot. A record read across sections: a delegate
// holding the whole record sees it, a scoped grant never does.
{
href: "/timeline",
sharedRecord: true,
tKey: "nav.timeline",
icon: History,
tourId: "nav-timeline",
},
// Insights belongs to no module: the `insights` key means AI analysis, and
// the area is data, so switching AI analysis off leaves this entry standing.
//
@@ -46,6 +46,7 @@ import {
Dumbbell,
FileScan,
FileText,
History,
MessageCircleHeart,
Leaf,
Moon,
@@ -117,6 +118,8 @@ const MODULE_ICONS: Record<ModuleKey, LucideIcon> = {
nutrients: Leaf,
// v1.38.0 — the immunization log.
vaccinations: Syringe,
// v1.42 — the life timeline (opt-in).
timeline: History,
};
export function ModulesSection() {
+4
View File
@@ -324,6 +324,9 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
// v1.40 — the person's own symptom names and the note on one occurrence.
{ model: "SymptomDefinition", field: "labelEncrypted", kind: "bytes" },
{ model: "SymptomEvent", field: "noteEncrypted", kind: "bytes" },
// v1.42 — a life event's title and note, in the person's own words.
{ model: "LifeEvent", field: "titleEncrypted", kind: "bytes" },
{ model: "LifeEvent", field: "noteEncrypted", kind: "bytes" },
// ───── v1.19.0 ECG waveform (Bytes column) ─────
{ model: "EcgRecording", field: "waveformEncrypted", kind: "bytes" },
@@ -598,6 +601,7 @@ export const UPDATED_AT_MODELS: ReadonlySet<string> = new Set([
"InsightStatusCache",
"IntegrationStatus",
"LabResult",
"LifeEvent",
"Measurement",
"MedicationCategoryLabel",
"MedicationCourse",
@@ -144,6 +144,7 @@ function moduleMap(
mentalHealth: true,
nutrients: true,
vaccinations: true,
timeline: true,
};
return { ...base, ...overrides };
}
+2
View File
@@ -133,6 +133,8 @@ export const WIPE_MODELS = [
"IllnessDayLog",
// Before its definition and before the episode it may point at (SetNull).
"SymptomEvent",
// v1.42 — the person's life events. Nothing points at them.
"LifeEvent",
"EcgRecording",
"NutrientIntakeDay",
+128
View File
@@ -0,0 +1,128 @@
/**
* The day/timeline contract's own rules (v1.42, #613): the enums mirror the
* database, the life-event dates line up with their precision, and the day
* index refuses a window wider than it promises to answer.
*/
import { describe, expect, it } from "vitest";
import {
LifeEventCategory,
LifeEventPrecision,
} from "@/generated/prisma/enums";
import {
DAY_INDEX_MAX_SPAN_DAYS,
DAY_SECTION_KEYS,
LIFE_EVENT_CATEGORIES,
LIFE_EVENT_PRECISIONS,
MODEL_EXCLUDED_DAY_SECTIONS,
} from "../contract";
import {
dayIndexQuerySchema,
dayPathSchema,
lifeEventCreateSchema,
lifeEventUpdateSchema,
} from "../wire-schemas";
const event = {
category: "HOME",
startDate: "2023-09-01",
precision: "MONTH",
title: "Moved",
} as const;
describe("the contract mirrors the schema", () => {
it("lists exactly the database's life-event categories and precisions", () => {
expect([...LIFE_EVENT_CATEGORIES].sort()).toEqual(
Object.values(LifeEventCategory).sort(),
);
expect([...LIFE_EVENT_PRECISIONS].sort()).toEqual(
Object.values(LifeEventPrecision).sort(),
);
});
it("keeps life events out of what a model sees", () => {
expect(MODEL_EXCLUDED_DAY_SECTIONS).toContain("lifeEvents");
for (const key of MODEL_EXCLUDED_DAY_SECTIONS) {
expect(DAY_SECTION_KEYS).toContain(key);
}
});
});
describe("dates", () => {
it("takes a real calendar date and refuses one that overflows", () => {
expect(dayPathSchema.safeParse({ date: "2026-03-29" }).success).toBe(true);
expect(dayPathSchema.safeParse({ date: "2026-02-30" }).success).toBe(false);
expect(dayPathSchema.safeParse({ date: "2026-3-9" }).success).toBe(false);
});
it("answers a day index of at most the promised span", () => {
const from = "2026-01-01";
const last = new Date(Date.parse(`${from}T00:00:00Z`));
last.setUTCDate(last.getUTCDate() + DAY_INDEX_MAX_SPAN_DAYS - 1);
const to = last.toISOString().slice(0, 10);
expect(dayIndexQuerySchema.safeParse({ from, to }).success).toBe(true);
last.setUTCDate(last.getUTCDate() + 1);
expect(
dayIndexQuerySchema.safeParse({
from,
to: last.toISOString().slice(0, 10),
}).success,
).toBe(false);
expect(
dayIndexQuerySchema.safeParse({ from: "2026-02-02", to: "2026-02-01" })
.success,
).toBe(false);
});
});
describe("a life event", () => {
it("accepts a month-precise date on the first of the month", () => {
expect(lifeEventCreateSchema.safeParse(event).success).toBe(true);
});
it("refuses a date that does not line up with its precision", () => {
expect(
lifeEventCreateSchema.safeParse({ ...event, startDate: "2023-09-14" })
.success,
).toBe(false);
expect(
lifeEventCreateSchema.safeParse({
...event,
precision: "YEAR",
startDate: "2023-09-01",
}).success,
).toBe(false);
expect(
lifeEventCreateSchema.safeParse({
...event,
precision: "YEAR",
startDate: "2019-01-01",
}).success,
).toBe(true);
});
it("refuses an end before its start", () => {
expect(
lifeEventCreateSchema.safeParse({
...event,
precision: "DAY",
startDate: "2026-03-02",
endDate: "2026-03-01",
}).success,
).toBe(false);
});
it("refuses an empty title and an unknown key", () => {
expect(
lifeEventCreateSchema.safeParse({ ...event, title: " " }).success,
).toBe(false);
expect(
lifeEventCreateSchema.safeParse({ ...event, userId: "someone-else" })
.success,
).toBe(false);
expect(
lifeEventUpdateSchema.safeParse({ userId: "someone-else" }).success,
).toBe(false);
});
});
+362
View File
@@ -0,0 +1,362 @@
/**
* The day view and the timeline: the contract every part of v1.42 builds
* against (#613).
*
* One file, so the server that answers, the web views that render and the
* native client that decodes agree on one set of names. Constants and types
* only, no Zod and no server import: the navigation chrome and the surface map
* import from here, and neither may pull a validator into the client bundle.
* The request and response schemas live beside it in `./wire-schemas.ts`, and
* every response type below is inferred from them, so the two cannot drift.
*
* Five route families speak it:
*
* GET /api/day/{date} one local day across the record
* GET /api/day/index which days hold anything, for a window
* GET /api/timeline lanes, standing items and value series
* GET /api/timeline/readiness what the timeline can already show
* GET /api/life-events the record's life events
* POST /api/life-events
* PATCH /api/life-events/{id}
* DELETE /api/life-events/{id}
*
* Rules the shapes carry rather than restate:
*
* - A day is the local calendar day in the record's time zone, never a UTC
* slice. The day route reads the rows live; the rollups cut at UTC.
* - The server decides and the clients render. Nothing here asks a client to
* recompute a band, a running-day count or a verdict. `notable` and the
* readiness details are keys with parameters; the wording lives in the
* message bundles, never in a model.
* - A section of a switched-off module is absent, not empty. A section the
* caller's grant does not cover is named in `sections` with
* `not_shared`, so the view can say so once.
* - Life events are part of the `profile` sharing domain and never reach a
* model: the model-facing projection of a day drops
* {@link MODEL_EXCLUDED_DAY_SECTIONS}.
*/
import type { z } from "zod/v4";
import type {
dayIndexResponseSchema,
dayResponseSchema,
lifeEventCreateSchema,
lifeEventListResponseSchema,
lifeEventSchema,
lifeEventUpdateSchema,
timelineQuerySchema,
timelineReadinessResponseSchema,
timelineResponseSchema,
} from "./wire-schemas";
/* ─── Calendar dates ──────────────────────────────────────────────────────── */
/**
* A local calendar date, `YYYY-MM-DD`. The `?day=` parameter, the day route's
* path segment and every date-only field below use it.
*/
export type DateKey = string;
/** The URL parameter that opens the day layer over any page. */
export const DAY_QUERY_PARAM = "day";
/** Widest window `GET /api/day/index` answers, in days, both ends included. */
export const DAY_INDEX_MAX_SPAN_DAYS = 366;
/* ─── Day sections ────────────────────────────────────────────────────────── */
/**
* The sections of a day, in the order the view shows them. Each one is a
* surface in `src/lib/modules/surface.ts` (`day-section:<key>`) when a module
* owns it; a section without an owner is part of the core record.
*
* values readings in the local day window, canonical source per type
* sleep the night that ended on this morning
* mood mood entries for the date
* assessments mental-health screeners taken that day
* medications intakes, dose changes, pauses, course starts and ends
* illness episodes running or starting, their day logs
* symptoms occurrences of person-defined symptoms
* allergies an allergy whose onset is this day
* labs results drawn that day
* visits visits and procedures
* vaccinations doses given that day
* checkups preventive check-ups completed that day
* documents documents dated that day
* workouts sessions started that day
* cycle the cycle phase and the day log
* environment the day's environment row and a travel period
* lifestyle anamnesis facts valid on that day (shift work, …)
* lifeEvents life events dated that day at DAY precision, or spanning it
*/
export const DAY_SECTION_KEYS = [
"values",
"sleep",
"mood",
"assessments",
"medications",
"illness",
"symptoms",
"allergies",
"labs",
"visits",
"vaccinations",
"checkups",
"documents",
"workouts",
"cycle",
"environment",
"lifestyle",
"lifeEvents",
] as const;
export type DaySectionKey = (typeof DAY_SECTION_KEYS)[number];
/**
* Sections a model never sees, whatever it asks for. The Coach and the MCP
* endpoint read a day through `get_day`, and that projection drops these
* before anything leaves the instance (v1.42: life events go to no model).
*/
export const MODEL_EXCLUDED_DAY_SECTIONS = [
"lifeEvents",
] as const satisfies readonly DaySectionKey[];
/** Why a section is missing from a day the caller can otherwise read. */
export const DAY_SECTION_UNAVAILABLE_REASONS = [
/** The grant the caller reads under does not cover the section's domain. */
"not_shared",
/**
* The owning module is off. Only the model-facing projection (`get_day`)
* reports it, as `{ present: false, reason: "module_disabled" }`; the web
* and native views omit the section without a word.
*/
"module_disabled",
] as const;
export type DaySectionUnavailableReason =
(typeof DAY_SECTION_UNAVAILABLE_REASONS)[number];
/** Something that runs through the day rather than happening on it. */
export const DAY_RUNNING_KINDS = [
"medication",
"medicationCourse",
"medicationPause",
"illness",
"restMode",
"cyclePhase",
"travel",
"lifestyle",
"lifeEvent",
] as const;
export type DayRunningKind = (typeof DAY_RUNNING_KINDS)[number];
/** Something that happened on the day, at a time or on the date. */
export const DAY_EVENT_KINDS = [
"intake",
"doseChange",
"medicationStart",
"medicationEnd",
"pauseStart",
"pauseEnd",
"courseStart",
"courseEnd",
"illnessOnset",
"illnessResolved",
"illnessDayLog",
"symptom",
"allergyOnset",
"labResult",
"visit",
"procedure",
"vaccination",
"checkup",
"document",
"mood",
"assessment",
"workout",
"cycleDayLog",
"lifeEvent",
] as const;
export type DayEventKind = (typeof DAY_EVENT_KINDS)[number];
/**
* Deterministic, descriptive observations. No cause, no verdict: "highest
* morning value since March 2025", never "because of". The client words them
* from `day.notable.<kind>` with the parameters the server sends.
*
* extremeHigh / extremeLow highest or lowest daily value of a type for at
* least three months, with at least 30 days of
* history (`params.since`, `params.value`)
* firstValue the first reading of a type ever (`params.type`)
* gap a type that came at least three times a week
* and then stopped for 14 days (`params.days`);
* only in the visit preparation and readiness
*/
export const DAY_NOTABLE_KINDS = [
"extremeHigh",
"extremeLow",
"firstValue",
"gap",
] as const;
export type DayNotableKind = (typeof DAY_NOTABLE_KINDS)[number];
/* ─── Timeline ────────────────────────────────────────────────────────────── */
/** Zoom levels. `all` bundles hardest: no symptoms, no single intakes. */
export const TIMELINE_ZOOMS = ["all", "year", "quarter"] as const;
export type TimelineZoom = (typeof TIMELINE_ZOOMS)[number];
/**
* The lanes, top to bottom. An empty lane is not sent. Each lane a module
* owns is a surface (`timeline-lane:<key>`) and disappears with it.
*
* life life events, and travel periods from the environment module
* illness episodes, chronic conditions as open lines
* allergies allergies with an onset; the rest are `standing`
* medications medications and courses, dose marks, pauses as gaps
* vaccinations doses
* visits visits and procedures
* labs days with results
* documents dated documents
* cycle cycles, only with the module on
*/
export const TIMELINE_LANE_KEYS = [
"life",
"illness",
"allergies",
"medications",
"vaccinations",
"visits",
"labs",
"documents",
"cycle",
] as const;
export type TimelineLaneKey = (typeof TIMELINE_LANE_KEYS)[number];
/** What one lane item is. */
export const TIMELINE_ITEM_KINDS = [
"lifeEvent",
"travel",
"episode",
"chronic",
"allergy",
"medication",
"course",
"doseChange",
"pause",
"vaccination",
"visit",
"procedure",
"labDay",
"document",
"cycle",
] as const;
export type TimelineItemKind = (typeof TIMELINE_ITEM_KINDS)[number];
/**
* The readiness inventory, one row per lane plus the value series. `values`
* is always listed; `mood`, `environment` and `cycle` only with their module
* on.
*/
export const TIMELINE_READINESS_KEYS = [
"values",
...TIMELINE_LANE_KEYS,
"mood",
"environment",
] as const;
export type TimelineReadinessKey = (typeof TIMELINE_READINESS_KEYS)[number];
/** How well one lane is filled. Counts, never a score. */
export const TIMELINE_READINESS_STATUSES = [
"carries",
"thin",
"empty",
] as const;
export type TimelineReadinessStatus =
(typeof TIMELINE_READINESS_STATUSES)[number];
/**
* The one-sentence verdict: `carries` from three carrying lanes including
* the values, otherwise `thin`.
*/
export const TIMELINE_READINESS_VERDICTS = ["carries", "thin"] as const;
export type TimelineReadinessVerdict =
(typeof TIMELINE_READINESS_VERDICTS)[number];
/* ─── Life events ─────────────────────────────────────────────────────────── */
/** Mirrors the Prisma enum `LifeEventCategory`. */
export const LIFE_EVENT_CATEGORIES = [
"FAMILY",
"HOME",
"WORK",
"LOSS",
"OTHER",
] as const;
export type LifeEventCategory = (typeof LIFE_EVENT_CATEGORIES)[number];
/** Mirrors the Prisma enum `LifeEventPrecision`. Applies to both ends. */
export const LIFE_EVENT_PRECISIONS = ["DAY", "MONTH", "YEAR"] as const;
export type LifeEventPrecision = (typeof LIFE_EVENT_PRECISIONS)[number];
/** Longest title, in characters. */
export const LIFE_EVENT_TITLE_MAX = 120;
/** Longest note, in characters. */
export const LIFE_EVENT_NOTE_MAX = 2000;
/* ─── Coach and MCP ───────────────────────────────────────────────────────── */
/**
* The read tool the Coach and the MCP endpoint get for one day. Reserved
* here so both registries spell it the same; the loader is the day route's.
* Its answer never carries a {@link MODEL_EXCLUDED_DAY_SECTIONS} section.
*/
export const DAY_TOOL_NAME = "get_day";
/** The tool's single argument. */
export interface DayToolInput {
date: DateKey;
}
/* ─── Wire types, inferred from the schemas ───────────────────────────────── */
/** `GET /api/day/{date}` → `data`. */
export type DayResponse = z.infer<typeof dayResponseSchema>;
export type DayRunningItem = DayResponse["running"][number];
export type DayValue = DayResponse["values"][number];
export type DayEvent = DayResponse["events"][number];
export type DayNotable = DayResponse["notable"][number];
/** `GET /api/day/index` → `data`. */
export type DayIndexResponse = z.infer<typeof dayIndexResponseSchema>;
/** `GET /api/timeline` query and `data`. */
export type TimelineQuery = z.infer<typeof timelineQuerySchema>;
export type TimelineResponse = z.infer<typeof timelineResponseSchema>;
export type TimelineLane = TimelineResponse["lanes"][number];
export type TimelineItem = TimelineLane["items"][number];
export type TimelineSeries = TimelineResponse["series"][number];
/** `GET /api/timeline/readiness` → `data`. */
export type TimelineReadinessResponse = z.infer<
typeof timelineReadinessResponseSchema
>;
export type TimelineReadinessLane = TimelineReadinessResponse["lanes"][number];
/** One life event as the routes return it. */
export type LifeEventDTO = z.infer<typeof lifeEventSchema>;
export type LifeEventListResponse = z.infer<typeof lifeEventListResponseSchema>;
export type LifeEventCreateInput = z.infer<typeof lifeEventCreateSchema>;
export type LifeEventUpdateInput = z.infer<typeof lifeEventUpdateSchema>;
+407
View File
@@ -0,0 +1,407 @@
/**
* Zod schemas for the day view, the timeline and life events (v1.42, #613).
*
* The routes parse their input with these, the OpenAPI table publishes them,
* and `./contract.ts` infers its wire types from them, so the request a route
* accepts, the shape it promises and the type a client reads are one thing.
* Server and OpenAPI side only: client code imports the types from
* `./contract.ts`, never this file, so no validator lands in the browser.
*/
import { z } from "zod/v4";
import { dateOnlyAtNoonUtc, isCalendarDateKey } from "@/lib/tz/date-only";
import {
DAY_EVENT_KINDS,
DAY_INDEX_MAX_SPAN_DAYS,
DAY_NOTABLE_KINDS,
DAY_RUNNING_KINDS,
DAY_SECTION_KEYS,
DAY_SECTION_UNAVAILABLE_REASONS,
LIFE_EVENT_CATEGORIES,
LIFE_EVENT_NOTE_MAX,
LIFE_EVENT_PRECISIONS,
LIFE_EVENT_TITLE_MAX,
TIMELINE_ITEM_KINDS,
TIMELINE_LANE_KEYS,
TIMELINE_READINESS_KEYS,
TIMELINE_READINESS_STATUSES,
TIMELINE_READINESS_VERDICTS,
TIMELINE_ZOOMS,
} from "./contract";
/* ─── Shared pieces ───────────────────────────────────────────────────────── */
export const dateKeySchema = z
.string()
.refine(isCalendarDateKey, { message: "Expected a YYYY-MM-DD calendar date" })
.describe("A local calendar date, `YYYY-MM-DD`.");
const instant = z.iso.datetime({ offset: true });
const messageParams = z
.record(z.string(), z.union([z.string(), z.number()]))
.describe(
"Parameters for the message the client words from its own bundle. Dates are `YYYY-MM-DD`, values are numbers in the unit the entry names.",
);
const daySectionKey = z.enum(DAY_SECTION_KEYS);
/* ─── GET /api/day/{date} ─────────────────────────────────────────────────── */
export const dayPathSchema = z.object({ date: dateKeySchema });
const dayRunningItem = z
.object({
kind: z.enum(DAY_RUNNING_KINDS),
section: daySectionKey,
id: z.string(),
title: z
.string()
.describe("Decrypted server-side for the caller's own session."),
sub: z
.string()
.nullable()
.describe("A second line, already formatted (a dose, a destination)."),
since: dateKeySchema,
until: dateKeySchema.nullable().describe("Null while it is still open."),
dayIndex: z
.number()
.int()
.positive()
.nullable()
.describe("Day n of the period, counting `since` as day 1."),
dayCount: z
.number()
.int()
.positive()
.nullable()
.describe("Length of a closed period in days; null while open."),
href: z
.string()
.nullable()
.describe("In-app path to the record this came from."),
})
.meta({ id: "DayRunningItem" });
const dayValue = z
.object({
type: z.string().describe("A `MeasurementType`."),
value: z.number(),
unit: z.string(),
at: instant,
source: z
.string()
.describe("The `MeasurementSource` of the canonical row."),
band: z
.object({
lo: z.number(),
hi: z.number(),
n: z.number().int().nonnegative(),
})
.nullable()
.describe(
"The person's own usual range over the 30 days before this day, from their daily values. Null with too little history.",
),
})
.meta({ id: "DayValue" });
const dayEvent = z
.object({
at: instant
.nullable()
.describe("The instant, or null for something that has a date only."),
kind: z.enum(DAY_EVENT_KINDS),
section: daySectionKey,
id: z.string(),
title: z.string(),
meta: z.string().nullable(),
note: z
.string()
.nullable()
.describe("The person's own note, decrypted for the caller's session."),
docs: z
.array(z.object({ id: z.string(), name: z.string() }))
.describe("Documents filed against the entry."),
href: z.string().nullable(),
})
.meta({ id: "DayEvent" });
const dayNotable = z
.object({
kind: z.enum(DAY_NOTABLE_KINDS),
type: z.string().nullable().describe("The `MeasurementType` it is about."),
params: messageParams,
})
.meta({ id: "DayNotable" });
export const dayResponseSchema = z
.object({
date: dateKeySchema,
tz: z.string().describe("The IANA zone the day was cut in."),
counts: z.object({
values: z.number().int().nonnegative(),
entries: z.number().int().nonnegative(),
}),
running: z.array(dayRunningItem),
values: z.array(dayValue),
events: z.array(dayEvent),
notable: z.array(dayNotable),
sections: z
.partialRecord(
daySectionKey,
z.object({
available: z.literal(false),
reason: z.enum(DAY_SECTION_UNAVAILABLE_REASONS),
}),
)
.describe(
"Sections the caller cannot see. A section of a switched-off module is left out entirely rather than listed.",
),
})
.meta({ id: "Day" });
/* ─── GET /api/day/index ──────────────────────────────────────────────────── */
export const dayIndexQuerySchema = z
.object({ from: dateKeySchema, to: dateKeySchema })
.refine((q) => q.from <= q.to, {
message: "`from` must not be after `to`",
path: ["to"],
})
.refine(
// Both ends as the same instant of their calendar day, so the difference
// counts calendar days whatever the process zone.
(q) =>
(dateOnlyAtNoonUtc(q.to).getTime() -
dateOnlyAtNoonUtc(q.from).getTime()) /
86_400_000 <
DAY_INDEX_MAX_SPAN_DAYS,
{
message: `At most ${DAY_INDEX_MAX_SPAN_DAYS} days`,
path: ["to"],
},
);
export const dayIndexResponseSchema = z
.object({
from: dateKeySchema,
to: dateKeySchema,
days: z
.record(z.string(), z.array(daySectionKey))
.describe(
"Each day in the window that holds anything, keyed `YYYY-MM-DD`, with the sections it holds. Days without entries are absent.",
),
notable: z
.array(z.string())
.describe("Days in the window that carry a notable observation."),
})
.meta({ id: "DayIndex" });
/* ─── GET /api/timeline ───────────────────────────────────────────────────── */
export const timelineQuerySchema = z.object({
zoom: z.enum(TIMELINE_ZOOMS).default("all"),
from: dateKeySchema.optional(),
to: dateKeySchema.optional(),
values: z
.string()
.optional()
.describe(
"Comma-separated value series to include, as `MeasurementType` names or `MOOD`.",
),
});
const timelineItem = z
.object({
id: z.string(),
kind: z.enum(TIMELINE_ITEM_KINDS),
start: dateKeySchema,
end: dateKeySchema.nullable().describe("Null for a point or an open span."),
open: z.boolean().describe("A span that has not ended."),
precision: z.enum(LIFE_EVENT_PRECISIONS),
startKnown: z
.boolean()
.describe(
"False when the start is a stand-in (a medication without a start date starts at its first intake).",
),
label: z.string(),
sub: z.string().nullable(),
href: z.string().nullable(),
})
.meta({ id: "TimelineItem" });
const timelineStanding = z
.object({
lane: z.enum(TIMELINE_LANE_KEYS),
id: z.string(),
label: z.string(),
since: dateKeySchema.nullable(),
href: z.string().nullable(),
})
.meta({ id: "TimelineStandingItem" });
const timelineSeries = z
.object({
key: z.string().describe("A `MeasurementType` or `MOOD`."),
unit: z.string().nullable(),
granularity: z.enum(["month", "week", "day"]),
points: z.array(z.object({ t: dateKeySchema, mean: z.number() })),
})
.meta({ id: "TimelineSeries" });
export const timelineResponseSchema = z
.object({
zoom: z.enum(TIMELINE_ZOOMS),
range: z.object({
from: dateKeySchema,
to: dateKeySchema,
dataFrom: dateKeySchema
.nullable()
.describe("The earliest date anything in the record carries."),
}),
lanes: z.array(
z.object({
key: z.enum(TIMELINE_LANE_KEYS),
items: z.array(timelineItem),
}),
),
standing: z
.array(timelineStanding)
.describe("Things without a start: shown once as standing."),
series: z.array(timelineSeries),
notable: z.array(
z.object({ date: dateKeySchema, kind: z.enum(DAY_NOTABLE_KINDS) }),
),
})
.meta({ id: "Timeline" });
/* ─── GET /api/timeline/readiness ─────────────────────────────────────────── */
export const timelineReadinessResponseSchema = z
.object({
verdict: z.enum(TIMELINE_READINESS_VERDICTS),
since: dateKeySchema
.nullable()
.describe("The earliest date the carrying lanes reach back to."),
lanes: z.array(
z.object({
key: z.enum(TIMELINE_READINESS_KEYS),
status: z.enum(TIMELINE_READINESS_STATUSES),
count: z.number().int().nonnegative(),
detail: z
.object({ key: z.string(), params: messageParams })
.nullable()
.describe("Worded by the client from `timeline.readiness.detail`."),
gaps: z.array(
z.object({
key: z.string(),
count: z.number().int().nonnegative(),
href: z.string(),
}),
),
}),
),
})
.meta({ id: "TimelineReadiness" });
/* ─── /api/life-events ────────────────────────────────────────────────────── */
/**
* At MONTH precision a date is the first of its month, at YEAR the first of
* its year. Storing the stand-in day keeps the column sortable and the rule
* keeps two spellings of "September 2023" from existing.
*/
function alignedToPrecision(
date: string,
precision: (typeof LIFE_EVENT_PRECISIONS)[number],
): boolean {
if (precision === "MONTH") return date.endsWith("-01");
if (precision === "YEAR") return date.endsWith("-01-01");
return true;
}
const lifeEventTitle = z.string().trim().min(1).max(LIFE_EVENT_TITLE_MAX);
const lifeEventNote = z.string().trim().max(LIFE_EVENT_NOTE_MAX);
export const lifeEventSchema = z
.object({
id: z.string(),
category: z.enum(LIFE_EVENT_CATEGORIES),
startDate: dateKeySchema,
endDate: dateKeySchema.nullable(),
precision: z.enum(LIFE_EVENT_PRECISIONS),
title: z
.string()
.nullable()
.describe("Decrypted; null only when the stored title cannot be read."),
note: z.string().nullable(),
createdAt: instant,
updatedAt: instant,
})
.meta({
id: "LifeEvent",
description:
"A dated anchor in the person's life. Shared under the `profile` domain; never sent to a model.",
});
export const lifeEventListResponseSchema = z
.object({ events: z.array(lifeEventSchema) })
.meta({ id: "LifeEventList" });
export const lifeEventCreateSchema = z
.object({
category: z.enum(LIFE_EVENT_CATEGORIES),
startDate: dateKeySchema,
endDate: dateKeySchema.nullable().optional(),
precision: z.enum(LIFE_EVENT_PRECISIONS),
title: lifeEventTitle,
note: lifeEventNote.nullable().optional(),
})
.strict()
.superRefine((value, ctx) => {
if (!alignedToPrecision(value.startDate, value.precision)) {
ctx.addIssue({
code: "custom",
path: ["startDate"],
message: "The date must be the first day of its month or year",
});
}
if (value.endDate != null) {
if (!alignedToPrecision(value.endDate, value.precision)) {
ctx.addIssue({
code: "custom",
path: ["endDate"],
message: "The date must be the first day of its month or year",
});
}
if (value.endDate < value.startDate) {
ctx.addIssue({
code: "custom",
path: ["endDate"],
message: "`endDate` must not be before `startDate`",
});
}
}
})
.meta({ id: "CreateLifeEventRequest" });
/**
* A partial edit. The cross-field rules are checked against the stored row,
* because a precision change alone can misalign a date the request does not
* carry; the route merges and re-checks with `lifeEventCreateSchema`.
*/
export const lifeEventUpdateSchema = z
.object({
category: z.enum(LIFE_EVENT_CATEGORIES),
startDate: dateKeySchema,
endDate: dateKeySchema.nullable(),
precision: z.enum(LIFE_EVENT_PRECISIONS),
title: lifeEventTitle,
note: lifeEventNote.nullable(),
})
.partial()
.strict()
.meta({ id: "UpdateLifeEventRequest" });
export const lifeEventIdPathSchema = z.object({ id: z.string() });
@@ -444,6 +444,7 @@ function makePrisma() {
// builder must still answer for.
ecgRecording: { findMany: vi.fn().mockResolvedValue([]) },
symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) },
lifeEvent: { findMany: vi.fn().mockResolvedValue([]) },
// Left unmocked on purpose: `buildProfileBackupSection` runs for real
// against these, so the assertions below exercise the builder rather than
// a stand-in that would agree with whatever the payload happened to do.
@@ -315,6 +315,7 @@ function sourceClient() {
extractedFact: { findMany: vi.fn().mockResolvedValue([]) },
ecgRecording: { findMany: vi.fn().mockResolvedValue([]) },
symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) },
lifeEvent: { findMany: vi.fn().mockResolvedValue([]) },
customMetric: {
findMany: vi.fn().mockResolvedValue([
{
+7
View File
@@ -111,6 +111,9 @@ export const BACKED_UP_MODELS = [
// else holds either; a restore without them loses the history outright.
"SymptomDefinition",
"SymptomEvent",
// v1.42 — the person's life events, the timeline's anchors. Written by the
// person and held nowhere else.
"LifeEvent",
"EcgRecording",
"UserHealthProfile",
"HealthProfileFactRevision",
@@ -274,6 +277,8 @@ export const BACKUP_WRITER_FILES: readonly string[] = [
// ride inside their definition (`include: { events }` out, `events: {
// create }` back), the way a medication's side effects do.
"src/lib/export/symptoms-backup.ts",
// The person's life events, both ends beside each other (v1.42).
"src/lib/export/life-events-backup.ts",
"src/lib/cycle/backup.ts",
// The medication category is read through the one helper that normalises
// it; the payload writer calls the helper rather than the delegate. The
@@ -297,6 +302,7 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [
"src/lib/export/onboarding-backup.ts",
"src/lib/export/ecg-backup.ts",
"src/lib/export/symptoms-backup.ts",
"src/lib/export/life-events-backup.ts",
"src/lib/cycle/backup.ts",
// The restore writes the medication category through the same helper.
"src/lib/medication-category.ts",
@@ -344,6 +350,7 @@ export const TWO_ENDED_MODELS = [
"IllnessSymptomLink",
"SymptomDefinition",
"SymptomEvent",
"LifeEvent",
"UserHealthProfile",
"HealthProfileFactRevision",
"CycleProfile",
+17 -1
View File
@@ -115,6 +115,12 @@ import {
type SymptomsBackupCounts,
type SymptomsBackupSection,
} from "@/lib/export/symptoms-backup";
import {
buildLifeEventsBackupSection,
countLifeEventsBackupSection,
type LifeEventsBackupCounts,
type LifeEventsBackupSection,
} from "@/lib/export/life-events-backup";
import {
buildIntradayProfileBackupSection,
countIntradayProfileBackupSection,
@@ -153,7 +159,8 @@ export interface FullBackupCounts
EnvironmentBackupCounts,
OnboardingBackupCounts,
EcgBackupCounts,
SymptomsBackupCounts {
SymptomsBackupCounts,
LifeEventsBackupCounts {
measurements: number;
medications: number;
intakeEvents: number;
@@ -724,6 +731,7 @@ export async function buildFullBackupPayload(
onboardingRecord,
ecg,
symptoms,
lifeEvents,
nutrientDays,
accountSettings,
] = await Promise.all([
@@ -908,6 +916,11 @@ export async function buildFullBackupPayload(
buildSymptomsBackupSection(prisma, userId, {
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
}),
// The person's life events (v1.42). Both ends live in
// `src/lib/export/life-events-backup.ts`.
buildLifeEventsBackupSection(prisma, userId, {
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
}),
// Nutrient day totals were absent from every export path, which
// contradicted the schema's own reason for denormalising the unit column
// ("rows stay self-describing in exports even if the catalog ever drifts").
@@ -962,6 +975,7 @@ export async function buildFullBackupPayload(
const environmentSection: EnvironmentBackupSection = environment;
const ecgSection: EcgBackupSection = ecg;
const symptomsSection: SymptomsBackupSection = symptoms;
const lifeEventsSection: LifeEventsBackupSection = lifeEvents;
const accountSettingsSection: AccountSettingsBackupSection = accountSettings;
// The three unbounded tables, either read into arrays or left as markers
@@ -1363,6 +1377,7 @@ export async function buildFullBackupPayload(
...environmentSection,
...ecgSection,
...symptomsSection,
...lifeEventsSection,
nutrientDays: nutrientDays.map((n) => ({
day: n.day,
nutrient: n.nutrient,
@@ -1431,6 +1446,7 @@ export async function buildFullBackupPayload(
...countEnvironmentBackupSection(environment),
...countEcgBackupSection(ecg),
...countSymptomsBackupSection(symptoms),
...countLifeEventsBackupSection(lifeEvents),
},
};
}
+205
View File
@@ -0,0 +1,205 @@
/**
* Life events (v1.42, #613), with both backup ends in one file.
*
* Same arrangement as `symptoms-backup.ts`: a reader asking "is this carried
* at both ends?" answers it here, and a reader who greps only the restore
* route gets a false negative because the route delegates.
*
* ## Free text follows the note contract
*
* A disaster-recovery payload carries the title and note ciphertext verbatim
* as base64, which the same instance's key reads back unchanged, and keeps
* the soft-deleted rows with their tombstones so an undo window survives the
* round trip. A portable export decrypts both, because a portable file exists
* to be readable by the person who owns it and to restore under another
* instance's key, and leaves the deleted rows out. A title this instance
* cannot decrypt travels as the visible unreadable marker rather than as
* nothing, the rule `records-backup.ts` states for notes.
*
* Nothing references a life event and a life event references nothing but
* its owner, so the section restores in any order against the others.
*/
import { Buffer } from "node:buffer";
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import type {
LifeEventCategory,
LifeEventPrecision,
} from "@/generated/prisma/client";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { getEvent } from "@/lib/logging/context";
import { UNREADABLE_EXPORT_MARKER } from "./unreadable-marker";
export interface LifeEventsBackupOptions {
purpose?: "portable-export" | "disaster-recovery";
}
/** One life event as the backup carries it. */
export interface LifeEventBackupEntry {
/** Present in a disaster-recovery payload. */
id?: string;
category: LifeEventCategory;
startDate: string;
endDate: string | null;
precision: LifeEventPrecision;
/** Plaintext; present on a portable payload. */
title?: string;
/** Ciphertext as base64; present on a disaster-recovery payload. */
titleEncrypted?: string;
/** Plaintext; present on a portable payload. */
note?: string | null;
/** Ciphertext as base64; present on a disaster-recovery payload. */
noteEncrypted?: string | null;
createdAt: string;
updatedAt: string;
/** Present on a disaster-recovery payload. */
deletedAt?: string | null;
}
export interface LifeEventsBackupSection {
lifeEvents: LifeEventBackupEntry[];
}
export interface LifeEventsBackupCounts {
lifeEvents: number;
}
function base64(buf: Uint8Array): string {
return Buffer.from(buf).toString("base64");
}
function readTextForExport(buf: Uint8Array, field: string): string {
try {
return decryptFromBytes(buf);
} catch (err) {
getEvent()?.addWarning(
`life event ${field} decrypt failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return UNREADABLE_EXPORT_MARKER;
}
}
/** Build the life-event slice of a user's full backup. */
export async function buildLifeEventsBackupSection(
prisma: Pick<PrismaClient, "lifeEvent">,
userId: string,
options: LifeEventsBackupOptions = {},
): Promise<LifeEventsBackupSection> {
const disasterRecovery = options.purpose === "disaster-recovery";
const rows = await prisma.lifeEvent.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: [{ startDate: "asc" }, { createdAt: "asc" }, { id: "asc" }],
});
return {
lifeEvents: rows.map((row) => ({
...(disasterRecovery
? {
id: row.id,
titleEncrypted: base64(row.titleEncrypted),
noteEncrypted: row.noteEncrypted ? base64(row.noteEncrypted) : null,
}
: {
title: readTextForExport(row.titleEncrypted, "title"),
note: row.noteEncrypted
? readTextForExport(row.noteEncrypted, "note")
: null,
}),
category: row.category,
startDate: row.startDate,
endDate: row.endDate,
precision: row.precision,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
...(disasterRecovery
? { deletedAt: row.deletedAt?.toISOString() ?? null }
: {}),
})),
};
}
export function countLifeEventsBackupSection(
section: LifeEventsBackupSection,
): LifeEventsBackupCounts {
return { lifeEvents: section.lifeEvents.length };
}
/** Counts the life-event restore wiped, for the audit trail. */
export interface LifeEventsRestoreCleared {
lifeEvents: number;
}
/**
* What the parser hands over, which is looser than what the builder writes:
* a file from before v1.42 carries no key at all, and optional columns arrive
* as `undefined` rather than `null`.
*/
export interface LifeEventsRestoreInput {
lifeEvents: Array<{
id?: string | undefined;
category: LifeEventCategory;
startDate: string;
endDate?: string | null | undefined;
precision: LifeEventPrecision;
title?: string | undefined;
titleEncrypted?: string | undefined;
note?: string | null | undefined;
noteEncrypted?: string | null | undefined;
createdAt: string;
updatedAt: string;
deletedAt?: string | null | undefined;
}>;
}
function decodeBytes(encoded: string): Uint8Array<ArrayBuffer> {
const decoded = Buffer.from(encoded, "base64");
const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength));
bytes.set(decoded);
return bytes;
}
/**
* Re-create the account's life events. Delete-then-recreate inside the
* caller's transaction, like every other section.
*/
export async function restoreLifeEventsData(
tx: Prisma.TransactionClient,
ownerId: string,
payload: LifeEventsRestoreInput,
): Promise<LifeEventsRestoreCleared> {
const cleared = await tx.lifeEvent.deleteMany({ where: { userId: ownerId } });
if (payload.lifeEvents.length > 0) {
await tx.lifeEvent.createMany({
data: payload.lifeEvents.map((event) => ({
...(event.id ? { id: event.id } : {}),
userId: ownerId,
category: event.category,
startDate: event.startDate,
endDate: event.endDate ?? null,
precision: event.precision,
titleEncrypted:
event.titleEncrypted !== undefined
? decodeBytes(event.titleEncrypted)
: encryptToBytes(event.title ?? ""),
noteEncrypted:
event.noteEncrypted !== undefined
? event.noteEncrypted === null
? null
: decodeBytes(event.noteEncrypted)
: event.note
? encryptToBytes(event.note)
: null,
createdAt: new Date(event.createdAt),
updatedAt: new Date(event.updatedAt),
deletedAt: event.deletedAt ? new Date(event.deletedAt) : null,
})),
});
}
return { lifeEvents: cleared.count };
}
+13
View File
@@ -114,6 +114,7 @@ import { restoreAwardsData } from "@/lib/export/awards-backup";
import { restoreEnvironmentData } from "@/lib/export/environment-backup";
import { restoreEcgData } from "@/lib/export/ecg-backup";
import { restoreSymptomsData } from "@/lib/export/symptoms-backup";
import { restoreLifeEventsData } from "@/lib/export/life-events-backup";
import { restoredMedicationCreatedAt } from "@/lib/export/medication-created-at";
import {
encryptCategoryLabel,
@@ -195,6 +196,7 @@ export interface RestoreResponse {
environmentTravelLocations: number;
ecgRecordings: number;
symptomDefinitions: number;
lifeEvents: number;
};
}
@@ -2276,6 +2278,16 @@ export async function restoreBackup(
skips,
);
// The person's life events (v1.42). They reference nothing but the
// account, so they have no ordering constraint and sit beside the
// other person-made rows. Both ends live in
// `src/lib/export/life-events-backup.ts`.
const lifeEventsCleared = await restoreLifeEventsData(
tx,
ownerId,
payload,
);
reportSection("allergies");
for (const allergy of payload.allergies) {
await tx.allergy.create({
@@ -2663,6 +2675,7 @@ export async function restoreBackup(
environmentCleared.environmentTravelLocations,
ecgRecordings: ecgCleared.ecgRecordings,
symptomDefinitions: symptomsCleared.symptomDefinitions,
lifeEvents: lifeEventsCleared.lifeEvents,
};
assertNoNewForeignReferences(
foreignBefore,
+5 -1
View File
@@ -374,7 +374,11 @@ describe("registry — opt-in marker", () => {
// default-on disabled-allowlist now; their egress channels (the weather
// fetch, the document AI-read, the AI Coach exposure) are separately gated
// and stay off until explicitly configured.
const OPT_IN_KEYS = new Set(["mcp", "nutrients"]);
//
// v1.42 — `timeline` joins them for a different reason: it opens no channel,
// but it is a new way of reading the whole record, and the readiness
// inventory shown when it is switched on is meant to be the first contact.
const OPT_IN_KEYS = new Set(["mcp", "nutrients", "timeline"]);
it("marks only the egress-surface modules opt-in (every other is default-on)", () => {
for (const key of MODULE_KEYS) {
@@ -67,6 +67,7 @@ import {
} from "@/lib/insights/sub-page-metric";
import { TREND_CHART_CONFIG } from "@/lib/insights/trend-chart-select";
import { SYMPTOM_CHANNEL_PREFIX } from "@/lib/symptoms/shared";
import { DAY_SECTION_KEYS, TIMELINE_LANE_KEYS } from "@/lib/day/contract";
import {
SURFACE_KINDS,
SURFACE_MODULE,
@@ -156,6 +157,20 @@ describe("every surface id names a surface that exists", () => {
}
});
it("day-section: a section the day view renders", () => {
const keys = new Set<string>(DAY_SECTION_KEYS);
for (const id of localIds("day-section")) {
expect(keys.has(id), id).toBe(true);
}
});
it("timeline-lane: a lane the timeline renders", () => {
const keys = new Set<string>(TIMELINE_LANE_KEYS);
for (const id of localIds("timeline-lane")) {
expect(keys.has(id), id).toBe(true);
}
});
it("correlation: a discovery channel, a weather field or the lab pass", () => {
const channels = new Set<string>([
...DISCOVERY_BEHAVIOURS,
@@ -269,6 +284,10 @@ describe("every surface that shows a module's data is owned by it", () => {
["correlation:MEDICATION_COMPLIANCE", "medications"],
["correlation:SYMPTOM_SEVERITY", "illness"],
["correlation:LAB_DRAWS", "labs"],
["nav:/timeline", "timeline"],
["day-section:lifeEvents", "timeline"],
["day-section:symptoms", "illness"],
["timeline-lane:documents", "inboundDocuments"],
])("%s is owned by %s", (id, owner) => {
expect(surfaceModule(id)).toBe(owner);
});
+10
View File
@@ -44,6 +44,7 @@ type DomainDataClient = Pick<
| "mentalHealthAssessment"
| "nutrientIntakeDay"
| "vaccinationRecord"
| "lifeEvent"
>;
type DomainProbe = (
@@ -201,6 +202,15 @@ const MODULE_DOMAIN_PROBES: Record<OwnedModuleKey, DomainProbe> = {
select: { id: true },
}),
),
// The timeline's own content is the life events; everything else on it
// belongs to other modules and says nothing about whether this one is used.
timeline: (client, recordId) =>
anyRow(
client.lifeEvent.findFirst({
where: { userId: recordId, deletedAt: null },
select: { id: true },
}),
),
};
/**
+21
View File
@@ -173,6 +173,19 @@ export const MODULE_KEYS = [
// surfaces the person made content for; it does not silently retract a
// commitment the app made to them.
"vaccinations",
// v1.42 (#613) — the life timeline (`/timeline`): years at a glance, the
// record's conditions, medications, visits and documents in lanes beside the
// value series, plus the person's own life events. OPT-IN (off by default):
// not because it egresses (it does not), but because it is a new way of
// looking at the whole record, and the readiness inventory that greets the
// switch is the right first contact with it. The day view is NOT this
// module: it is core and opens for every account.
//
// Life events belong to this module. They are only created from the
// timeline and the day's capture sheet while it is on, and they disappear
// from the day with it; the `/api/life-events` row store stays reachable so
// a restore keeps working and re-enabling finds the events intact.
"timeline",
] as const;
export type ModuleKey = (typeof MODULE_KEYS)[number];
@@ -369,6 +382,14 @@ export const MODULE_REGISTRY: Readonly<Record<ModuleKey, ModuleDefinition>> =
// No `optIn`: default-on. Nothing leaves the instance and the surfaces
// are inert until the person logs a dose.
},
timeline: {
key: "timeline",
labelKey: "modules.timeline.label",
descriptionKey: "modules.timeline.description",
category: "tracking",
// Off by default; see the key's comment in `MODULE_KEYS`.
optIn: true,
},
});
const MODULE_KEY_SET: ReadonlySet<string> = new Set(MODULE_KEYS);
+40
View File
@@ -48,6 +48,10 @@ import type { ModuleKey } from "@/lib/modules/registry";
* - `settings-layout`: a Settings, Layout group, by its id.
* - `correlation`: a correlation-discovery channel, by its channel key.
* - `score-ring`: a dashboard hero score ring, by its `ScoreRingId`.
* - `day-section`: a section of the day view, by its `DaySectionKey`. The
* day loader blanks a section whose owner is off, and the views drop it.
* - `timeline-lane`: a lane of the timeline, by its `TimelineLaneKey`; the
* readiness inventory leaves it out the same way.
*/
export const SURFACE_KINDS = [
"nav",
@@ -62,6 +66,8 @@ export const SURFACE_KINDS = [
"settings-layout",
"correlation",
"score-ring",
"day-section",
"timeline-lane",
] as const;
export type SurfaceKind = (typeof SURFACE_KINDS)[number];
@@ -79,6 +85,7 @@ const STATIC_SURFACE_MODULE = {
"nav:/documents": "inboundDocuments",
"nav:/coach": "coach",
"nav:/achievements": "achievements",
"nav:/timeline": "timeline",
// ── Insights sub-pages ──
"insights-page:mood": "mood",
@@ -205,6 +212,39 @@ const STATIC_SURFACE_MODULE = {
"correlation:SYMPTOM_SEVERITY": "illness",
// The labs ↔ outcome pass over lab draws.
"correlation:LAB_DRAWS": "labs",
// ── Day view sections (v1.42) ──
// The readings carry no owner here: each value follows its own type's
// module (`moduleForMeasurementType`), so a section-level owner would hide
// the core vitals with it. Allergies, visits, check-ups and the anamnesis
// facts are core record surfaces and stay unowned.
"day-section:sleep": "sleep",
"day-section:mood": "mood",
"day-section:assessments": "mentalHealth",
"day-section:medications": "medications",
"day-section:illness": "illness",
// Person-defined symptoms ride the illness module, as everywhere else.
"day-section:symptoms": "illness",
"day-section:labs": "labs",
"day-section:vaccinations": "vaccinations",
"day-section:documents": "inboundDocuments",
"day-section:workouts": "workouts",
"day-section:cycle": "cycle",
"day-section:environment": "environment",
// Life events are the timeline module's own content: off, they leave the
// day as well.
"day-section:lifeEvents": "timeline",
// ── Timeline lanes (v1.42) ──
// The `life` lane mixes life events (this module) with travel periods (the
// environment module) and is filtered item by item; allergies and visits are
// core.
"timeline-lane:illness": "illness",
"timeline-lane:medications": "medications",
"timeline-lane:vaccinations": "vaccinations",
"timeline-lane:labs": "labs",
"timeline-lane:documents": "inboundDocuments",
"timeline-lane:cycle": "cycle",
} as const satisfies Record<string, ModuleKey>;
/** A surface id the map declares literally. */
+6 -1
View File
@@ -46,7 +46,12 @@ export const SHARED_RECORD_DOMAIN_ROUTE_FAMILIES = {
* Routes whose reads span multiple domains. A selected-domain grant never
* opens these, even when it happens to name every currently known domain.
*/
const WHOLE_RECORD_ROUTE_FAMILIES = ["/", "/achievements"] as const;
const WHOLE_RECORD_ROUTE_FAMILIES = [
"/",
"/achievements",
// v1.42 — the timeline lays every section side by side.
"/timeline",
] as const;
interface RouteFamily {
href: string;
+229
View File
@@ -0,0 +1,229 @@
/**
* OpenAPI route table for the day view, the timeline and life events
* (`/api/day`, `/api/timeline`, `/api/life-events`, v1.42, #613).
*
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request
* and response schemas are the runtime ones from `@/lib/day/wire-schemas`, so
* the published contract is the parser's own. Published ahead of the
* implementation: every operation answers 501 until its route lands.
*/
import type { ZodOpenApiObject } from "zod-openapi";
import {
dayIndexQuerySchema,
dayIndexResponseSchema,
dayPathSchema,
dayResponseSchema,
lifeEventCreateSchema,
lifeEventIdPathSchema,
lifeEventListResponseSchema,
lifeEventSchema,
lifeEventUpdateSchema,
timelineQuerySchema,
timelineReadinessResponseSchema,
timelineResponseSchema,
} from "@/lib/day/wire-schemas";
import {
dataEnvelope,
errorEnvelope,
notImplementedResponse,
recordRefusal,
recordWriteRateLimitResponse,
stdResponses,
} from "./shared";
const TIMELINE_OFF =
"`module.disabled` with `meta.module = timeline`: the record has the timeline switched off (it is opt-in), or the operator turned it off server-wide.";
const lifeEventNotFound = {
"404": {
description: "No such life event on this record.",
content: { "application/json": { schema: errorEnvelope } },
},
};
export const dayPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/day/index": {
get: {
tags: ["Records"],
summary: "Days with entries",
description:
"Which local days in a window hold anything, by section, and which carry a notable observation. At most 366 days per call. Days are cut in the record's own time zone.",
requestParams: { query: dayIndexQuerySchema },
responses: {
"200": {
description: "The days in the window that hold entries.",
content: {
"application/json": {
schema: dataEnvelope(dayIndexResponseSchema, "DayIndexEnvelope"),
},
},
},
...stdResponses,
...recordRefusal(),
...notImplementedResponse,
},
},
},
"/api/day/{date}": {
get: {
tags: ["Records"],
summary: "One day across the record",
description:
"Everything on one local calendar day: what ran through it (medications, an illness, a cycle phase, a trip), the readings in its window with the person's usual range, what happened on it, and deterministic notable observations as keys with parameters. Sections of a switched-off module are left out; sections the caller's grant does not cover are named in `sections`. Read live from the record, never from the UTC rollups. A date that is not a real calendar date answers 422.",
requestParams: { path: dayPathSchema },
responses: {
"200": {
description: "The day.",
content: {
"application/json": {
schema: dataEnvelope(dayResponseSchema, "DayEnvelope"),
},
},
},
...stdResponses,
...recordRefusal(),
...notImplementedResponse,
},
},
},
"/api/timeline": {
get: {
tags: ["Records"],
summary: "The record over the years",
description:
"Lanes of conditions, allergies, medications, vaccinations, visits, lab days, documents and life events, the standing items without a start, and monthly (or finer, by zoom) value series. Empty lanes are not sent; lanes of a switched-off module never are. Requires the opt-in `timeline` module.",
requestParams: { query: timelineQuerySchema },
responses: {
"200": {
description: "The timeline for the requested zoom and window.",
content: {
"application/json": {
schema: dataEnvelope(timelineResponseSchema, "TimelineEnvelope"),
},
},
},
...stdResponses,
...recordRefusal(TIMELINE_OFF),
...notImplementedResponse,
},
},
},
"/api/timeline/readiness": {
get: {
tags: ["Records"],
summary: "What the timeline can show",
description:
"One row per lane: carries, thin or empty, a count, a detail as a message key with parameters, and the gaps with one in-app link each. The verdict is one of two words, never a score. Requires the opt-in `timeline` module.",
responses: {
"200": {
description: "The readiness inventory.",
content: {
"application/json": {
schema: dataEnvelope(
timelineReadinessResponseSchema,
"TimelineReadinessEnvelope",
),
},
},
},
...stdResponses,
...recordRefusal(TIMELINE_OFF),
...notImplementedResponse,
},
},
},
"/api/life-events": {
get: {
tags: ["Records"],
summary: "List life events",
description:
"The record's life events, oldest first; soft-deleted ones are excluded. Title and note are decrypted for the caller. Part of the `profile` sharing domain. Life events are never sent to a model.",
responses: {
"200": {
description: "The life events.",
content: {
"application/json": {
schema: dataEnvelope(
lifeEventListResponseSchema,
"LifeEventListEnvelope",
),
},
},
},
...stdResponses,
...recordRefusal(),
...notImplementedResponse,
},
},
post: {
tags: ["Records"],
summary: "Add a life event",
description:
"A dated anchor in the person's life. `precision` says how much of the date is known and applies to both ends: at MONTH a date is the first of its month, at YEAR the first of its year. `endDate`, when given, is not before `startDate`. Title (1-120 characters) and note (up to 2000) are encrypted at rest.",
requestBody: {
required: true,
content: { "application/json": { schema: lifeEventCreateSchema } },
},
responses: {
"201": {
description: "Created.",
content: {
"application/json": {
schema: dataEnvelope(lifeEventSchema, "CreateLifeEventEnvelope"),
},
},
},
...stdResponses,
...recordWriteRateLimitResponse,
...notImplementedResponse,
},
},
},
"/api/life-events/{id}": {
patch: {
tags: ["Records"],
summary: "Edit a life event",
description:
"A partial edit; an omitted key leaves the column untouched. The merged event must still satisfy the create rules (dates aligned to the precision, end not before start).",
requestParams: { path: lifeEventIdPathSchema },
requestBody: {
required: true,
content: { "application/json": { schema: lifeEventUpdateSchema } },
},
responses: {
"200": {
description: "Updated.",
content: {
"application/json": {
schema: dataEnvelope(lifeEventSchema, "UpdateLifeEventEnvelope"),
},
},
},
...lifeEventNotFound,
...stdResponses,
...notImplementedResponse,
},
},
delete: {
tags: ["Records"],
summary: "Delete a life event",
description: "Soft-deletes one life event.",
requestParams: { path: lifeEventIdPathSchema },
responses: {
"200": {
description: "Deleted.",
content: {
"application/json": {
schema: dataEnvelope(lifeEventSchema, "DeleteLifeEventEnvelope"),
},
},
},
...lifeEventNotFound,
...stdResponses,
...notImplementedResponse,
},
},
},
};
+4
View File
@@ -48,6 +48,7 @@ import { customMetricPaths } from "./custom-metrics";
import { dailyPaths } from "./daily";
import { cyclePaths } from "./cycle";
import { dashboardWidgetPaths } from "./dashboard";
import { dayPaths } from "./day";
import { biomarkerPaths } from "./biomarkers";
import { inboundDocumentPaths } from "./documents";
import { documentSourcePaths } from "./document-sources";
@@ -181,6 +182,9 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
// v1.40 — person-defined symptoms and their occurrences, behind the illness
// module (appended, spread order is load-bearing).
...symptomPaths,
// v1.42 — the day view, the timeline and life events (appended, spread
// order is load-bearing).
...dayPaths,
// The environmental-context overview. Its own module because nothing else
// owns the surface (appended, spread order is load-bearing).
//
+18
View File
@@ -0,0 +1,18 @@
/**
* Query keys — the day view (v1.42, #613): one local day, and the index of
* days that hold anything over a window.
* Part of the centralized factory; aggregated in `./index.ts`.
*
* Everything sits under the `["day"]` root so a write to any record table can
* evict every open day at once: the record data bundles in `./index.ts` carry
* the root, and a day is cheap enough to re-read that finer keys would only
* add ways to miss one.
*/
export const dayKeys = {
/** Root prefix; the data-dependent bundles invalidate through it. */
dayRoot: () => ["day"] as const,
/** `GET /api/day/{date}`. */
day: (date: string) => ["day", "detail", date] as const,
/** `GET /api/day/index`: the window bounds are the key. */
dayIndex: (from: string, to: string) => ["day", "index", from, to] as const,
};
+36 -1
View File
@@ -24,6 +24,7 @@ import { customMetrics } from "./custom-metrics";
import { cycleKeys } from "./cycle";
import { dailyKeys } from "./daily";
import { dashboardKeys } from "./dashboard";
import { dayKeys } from "./day";
import { environmentKeys } from "./environment";
import { documentKeys } from "./documents";
import { encounterKeys } from "./encounters";
@@ -41,6 +42,7 @@ import { onboardingKeys } from "./onboarding";
import { recordSettingsKeys } from "./record-settings";
import { profileKeys } from "./profile";
import { settingsKeys } from "./settings";
import { timelineKeys } from "./timeline";
import { sharingKeys } from "./sharing";
import { vaccinationKeys } from "./vaccinations";
import { workoutKeys } from "./workouts";
@@ -76,6 +78,8 @@ export const queryKeys = {
...sharingKeys,
...vaccinationKeys,
...aiRunKeys,
...dayKeys,
...timelineKeys,
};
export { recordSettingsKeys } from "./record-settings";
@@ -151,6 +155,10 @@ export const measurementDependentKeys = [
["sleep-night"] as const,
// v1.17.0 — refresh the sleep-debt + chronotype read when sleep rows change.
["sleep-rhythm"] as const,
// v1.42 — an open day and the timeline read every record table, so a
// write to this one evicts both roots.
queryKeys.dayRoot(),
queryKeys.timelineRoot(),
];
/**
@@ -181,6 +189,10 @@ export const moodDependentKeys = [
// server-side. Call sites force an inactive refetch too (see
// `refetchInactiveDailyReads`).
queryKeys.dailyDigest(),
// v1.42 — an open day and the timeline read every record table, so a
// write to this one evicts both roots.
queryKeys.dayRoot(),
queryKeys.timelineRoot(),
];
/**
@@ -232,6 +244,10 @@ export const medicationDependentKeys = [
queryKeys.dailyDigest(),
["dashboard-medication-compliance"] as const,
["compliance-chart-inline"] as const,
// v1.42 — an open day and the timeline read every record table, so a
// write to this one evicts both roots.
queryKeys.dayRoot(),
queryKeys.timelineRoot(),
];
/**
@@ -242,7 +258,14 @@ export const medicationDependentKeys = [
* log. `insightsRoot()` rides along because phase-correlation cards depend
* on the same rows.
*/
export const cycleDependentKeys = [queryKeys.cycle(), queryKeys.insightsRoot()];
export const cycleDependentKeys = [
queryKeys.cycle(),
queryKeys.insightsRoot(),
// v1.42 — an open day and the timeline read every record table, so a
// write to this one evicts both roots.
queryKeys.dayRoot(),
queryKeys.timelineRoot(),
];
/**
* Keys invalidated when an illness episode starts, ends, is edited, deleted,
@@ -261,6 +284,10 @@ export const illnessDependentKeys = [
// v1.39.2 — a condition's body site is half of the body-site view and of
// the suggestions a body-site field offers.
queryKeys.bodySitesAll(),
// v1.42 — an open day and the timeline read every record table, so a
// write to this one evicts both roots.
queryKeys.dayRoot(),
queryKeys.timelineRoot(),
];
/**
@@ -290,6 +317,10 @@ export const encounterDependentKeys = [
// seeds its replace-set visit picker from its own cached links: left
// stale, the next tap there would delete the link just made.
queryKeys.documents(),
// v1.42 — an open day and the timeline read every record table, so a
// write to this one evicts both roots.
queryKeys.dayRoot(),
queryKeys.timelineRoot(),
];
/**
@@ -313,6 +344,10 @@ export const vaccinationDependentKeys = [
// seeds its replace-set dose picker from its own cached links: left stale,
// the next tap there would delete the link just made.
queryKeys.documents(),
// v1.42 — an open day and the timeline read every record table, so a
// write to this one evicts both roots.
queryKeys.dayRoot(),
queryKeys.timelineRoot(),
];
/**
+26
View File
@@ -0,0 +1,26 @@
/**
* Query keys — the life timeline and life events (v1.42, #613).
* Part of the centralized factory; aggregated in `./index.ts`.
*
* The timeline, its readiness inventory and the life-event list share the
* `["timeline"]` root: a life-event write changes all three, and a write to any
* other record table evicts the root through the data bundles in `./index.ts`.
*/
export const timelineKeys = {
/** Root prefix; the data-dependent bundles invalidate through it. */
timelineRoot: () => ["timeline"] as const,
/**
* `GET /api/timeline`. Zoom, window and the chosen value series are the
* key: each combination is a different payload.
*/
timeline: (
zoom: string,
from: string | null,
to: string | null,
values: string,
) => ["timeline", "lanes", zoom, from, to, values] as const,
/** `GET /api/timeline/readiness`. */
timelineReadiness: () => ["timeline", "readiness"] as const,
/** `GET /api/life-events`. */
lifeEvents: () => ["timeline", "life-events"] as const,
};
+3
View File
@@ -76,6 +76,9 @@ export const MODULE_SHARE_DOMAIN: Readonly<
// An immunization history is health background, the section `/vaccinations`
// is presented under.
vaccinations: "profile",
// The timeline lays every section side by side. Its own content, the life
// events, sits in `profile`, but the surface reads across the record.
timeline: null,
});
/** Does a grant carrying these sections open this one? `null` is the whole record. */
+5
View File
@@ -64,6 +64,11 @@ export const SHARE_DOMAINS = [
* toggle, which is a separate question from consent — a module the owner
* enabled is not a section the owner shared, and a person granting health
* background is granting the section whether or not the surfaces are on.
*
* v1.42 adds the person's life events (a move, a birth, a loss) on the same
* terms: they are background to the record rather than a reading, the consent
* copy names them in every locale, and the timeline module that shows them is
* again a separate question from consent.
*/
"profile",
/** Illness episodes and the day logs under them. */
+34
View File
@@ -72,6 +72,8 @@ import {
SecondarySymptom,
SleepStage,
VaccinationSite,
LifeEventCategory,
LifeEventPrecision,
} from "@/generated/prisma/enums";
import {
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
@@ -1213,6 +1215,36 @@ const symptomDefinitionBackupSchema = z
message: "A symptom definition needs a label or its ciphertext",
});
/**
* A life event (v1.42, #613). `title` / `titleEncrypted` and `note` /
* `noteEncrypted` are the two ends of the free-text contract: a portable file
* carries the plaintext, a disaster-recovery file the ciphertext and the
* tombstone. Dates are calendar strings.
*/
const lifeEventBackupSchema = z
.object({
id: z.string().min(1).optional(),
category: z.enum(LifeEventCategory),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
endDate: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.nullable()
.optional(),
precision: z.enum(LifeEventPrecision),
title: z.string().min(1).optional(),
titleEncrypted: base64BytesSchema.optional(),
note: z.string().nullable().optional(),
noteEncrypted: base64BytesSchema.nullable().optional(),
createdAt: isoDateTime,
updatedAt: isoDateTime,
deletedAt: isoDateTime.nullable().optional(),
})
.passthrough()
.refine((e) => e.title !== undefined || e.titleEncrypted !== undefined, {
message: "A life event needs a title or its ciphertext",
});
/**
* A practitioner, an encounter, and the edges between an encounter and the
* things it produced.
@@ -2038,6 +2070,8 @@ export const backupPayloadSchema = z
illnessEpisodes: z.array(illnessEpisodeBackupSchema).default([]),
// Defaulted: a file written before v1.40 carries no key.
symptomDefinitions: z.array(symptomDefinitionBackupSchema).default([]),
// Defaulted: a file written before v1.42 carries no key.
lifeEvents: z.array(lifeEventBackupSchema).default([]),
allergies: z.array(allergyBackupSchema).default([]),
familyHistory: z.array(familyHistoryBackupSchema).default([]),
workouts: z.array(workoutBackupSchema).default([]),
@@ -252,6 +252,7 @@ const COUNT_BACK: Record<
SymptomDefinition: (p, userId) =>
p.symptomDefinition.count({ where: { userId } }),
SymptomEvent: (p, userId) => p.symptomEvent.count({ where: { userId } }),
LifeEvent: (p, userId) => p.lifeEvent.count({ where: { userId } }),
IllnessSymptomLink: (p, userId) =>
p.illnessSymptomLink.count({ where: { dayLog: { userId } } }),
UserHealthProfile: (p, userId) =>
@@ -849,6 +850,29 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
},
});
// v1.42 — two life events: a move known to the month, with a note, and a
// period known to the day.
await prisma.lifeEvent.create({
data: {
userId: OWNER_ID,
category: "HOME",
startDate: "2023-09-01",
precision: "MONTH",
titleEncrypted: encryptToBytes("Moved to the coast"),
noteEncrypted: encryptToBytes("the flat with the long hallway"),
},
});
await prisma.lifeEvent.create({
data: {
userId: OWNER_ID,
category: "WORK",
startDate: "2026-03-02",
endDate: "2026-05-29",
precision: "DAY",
titleEncrypted: encryptToBytes("Parental leave"),
},
});
await prisma.userHealthProfile.create({
data: {
userId: OWNER_ID,
@@ -2319,6 +2343,43 @@ describe("every model the plan claims two-ended survives a real restore", () =>
},
]);
// v1.42 — the life events: the titles and the note decrypt to what was
// typed, and the dates keep their precision.
const restoredLifeEvents = await prisma.lifeEvent.findMany({
where: { userId: OWNER_ID },
orderBy: { startDate: "asc" },
});
expect(
restoredLifeEvents.map((event) => ({
category: event.category,
startDate: event.startDate,
endDate: event.endDate,
precision: event.precision,
title: decryptFromBytes(event.titleEncrypted),
note: event.noteEncrypted
? decryptFromBytes(event.noteEncrypted)
: null,
})),
"life events must survive the round trip",
).toEqual([
{
category: "HOME",
startDate: "2023-09-01",
endDate: null,
precision: "MONTH",
title: "Moved to the coast",
note: "the flat with the long hallway",
},
{
category: "WORK",
startDate: "2026-03-02",
endDate: "2026-05-29",
precision: "DAY",
title: "Parental leave",
note: null,
},
]);
// The staged fact, and the decision on it.
//
// This is the assertion the count cannot make. `status`, `needsReview`,
@@ -32,6 +32,7 @@ const MODULES = {
mentalHealth: true,
nutrients: true,
vaccinations: true,
timeline: true,
} as const;
/** The dense path is about pulse and glucose; nothing else is in scope here. */
@@ -43,6 +43,7 @@ const MODULES = {
mentalHealth: true,
nutrients: true,
vaccinations: true,
timeline: true,
} as const;
const SELECTION = selectionFromLeaves(["PULSE"]);
@@ -346,6 +346,7 @@ describe("doctor-report — module enable/disable gating", () => {
mentalHealth: true,
nutrients: true,
vaccinations: true,
timeline: true,
} as const;
it("includes every module's data when all modules are enabled", async () => {