From c79fc6cc7bbbe4daf9945381ae9ee775080da5a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 13:03:37 +0200 Subject: [PATCH] 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. --- bundle-budget.json | 4 +- docs/api/openapi.yaml | 1331 +++++++++++++++++ knip.jsonc | 3 + messages/de.json | 142 +- messages/en.json | 142 +- messages/es.json | 142 +- messages/fr.json | 142 +- messages/it.json | 142 +- messages/ko.json | 142 +- messages/pl.json | 142 +- .../migrations/0387_life_event/migration.sql | 31 + prisma/schema.prisma | 69 + scripts/rotate-encryption-key.ts | 8 + src/__tests__/i18n-reverse-coverage.test.ts | 3 + src/__tests__/sharing-surface-guard.test.ts | 33 +- .../module-route-gate-inventory.test.ts | 13 + src/app/api/day/[date]/route.ts | 22 + src/app/api/day/index/route.ts | 16 + .../export/__tests__/per-type-routes.test.ts | 2 + .../__tests__/soft-delete-filter.test.ts | 8 + .../export/encrypted/__tests__/route.test.ts | 1 + src/app/api/life-events/[id]/route.ts | 21 + src/app/api/life-events/route.ts | 26 + src/app/api/timeline/readiness/route.ts | 18 + src/app/api/timeline/route.ts | 20 + src/components/layout/nav-model.ts | 13 + src/components/settings/modules-section.tsx | 3 + src/lib/crypto/encrypted-columns.ts | 4 + src/lib/dashboard/__tests__/snapshot.test.ts | 1 + src/lib/data-wipe/wipe-plan.ts | 2 + src/lib/day/__tests__/wire-schemas.test.ts | 128 ++ src/lib/day/contract.ts | 362 +++++ src/lib/day/wire-schemas.ts | 407 +++++ .../__tests__/full-backup-payload.test.ts | 1 + .../restore-backup-round-trip.test.ts | 1 + src/lib/export/backup-plan.ts | 7 + src/lib/export/full-backup-payload.ts | 18 +- src/lib/export/life-events-backup.ts | 205 +++ src/lib/export/restore-backup.ts | 13 + src/lib/modules/__tests__/gate.test.ts | 6 +- .../__tests__/surface-module-registry.test.ts | 19 + src/lib/modules/domain-data.ts | 10 + src/lib/modules/registry.ts | 21 + src/lib/modules/surface.ts | 40 + src/lib/navigation/shared-record.ts | 7 +- src/lib/openapi/routes/day.ts | 229 +++ src/lib/openapi/routes/index.ts | 4 + src/lib/query-keys/day.ts | 18 + src/lib/query-keys/index.ts | 37 +- src/lib/query-keys/timeline.ts | 26 + src/lib/sharing/module-disclosure.ts | 3 + src/lib/sharing/scope.ts | 5 + src/lib/validations/backup.ts | 34 + tests/integration/backup-round-trip.test.ts | 61 + .../doctor-report-dense-data.test.ts | 1 + .../doctor-report-pulse-hourly-mean.test.ts | 1 + .../doctor-report-sections.test.ts | 1 + 57 files changed, 4290 insertions(+), 21 deletions(-) create mode 100644 prisma/migrations/0387_life_event/migration.sql create mode 100644 src/app/api/day/[date]/route.ts create mode 100644 src/app/api/day/index/route.ts create mode 100644 src/app/api/life-events/[id]/route.ts create mode 100644 src/app/api/life-events/route.ts create mode 100644 src/app/api/timeline/readiness/route.ts create mode 100644 src/app/api/timeline/route.ts create mode 100644 src/lib/day/__tests__/wire-schemas.test.ts create mode 100644 src/lib/day/contract.ts create mode 100644 src/lib/day/wire-schemas.ts create mode 100644 src/lib/export/life-events-backup.ts create mode 100644 src/lib/openapi/routes/day.ts create mode 100644 src/lib/query-keys/day.ts create mode 100644 src/lib/query-keys/timeline.ts diff --git a/bundle-budget.json b/bundle-budget.json index 9cb56d61d..cb161380b 100644 --- a/bundle-budget.json +++ b/bundle-budget.json @@ -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 /insights and /measurements sat within a kilobyte of theirs and / and /insights/mood within one of theirs; e2e run 34043772954 failed on `/insights/page: 445 KB gz exceeds the 445 KB budget` with the other two at 459 of 460, and none of it was a regression anyone had made. A ceiling a route is already touching does not measure the route, it measures whichever dependency bump landed that week. The file states the measured value per route now, plus one drift allowance over it: measured on a local arm64 build at d48bbff01 as / 447, /insights 434, /measurements 433, /insights/mood 447, with a 20 KB allowance, so the effective ceilings are 467 / 454 / 453 / 467 — a raise for all four. The allowance covers the runner delta (the CI runner measures each route a little heavier than a local arm64 build) and ordinary week-to-week drift, while the step change the gate exists for — a statically imported message catalog, a duplicated chart library — is several times larger and still fails. Every run now prints the delta against the baseline, so a route that has crept 15 KB is legible in the log long before it is red; the flat ceiling could only ever say pass or fail. Re-base by re-measuring on a build with nothing else in it and writing the new numbers here with the reason, exactly as a raised ceiling was written before. Raised to 3724 at v1.39.4 on a measured delta, not a feature. The release branch alone measured 3699 KB gz (3698.9) against the 3700 cap, so the ordinary drift room the v1.37.32 raise left had been used up by the releases since; the review fixes on top of it added 1.3 KB gz across the whole build (3700.2), almost all of it new copy in the seven lazy locale catalogs (the total and reused-table labels, the step and chip wording) plus the result-panel states. The four eager routes measured the same on both builds (/ 458, /insights/mood 456, /insights 445, /measurements 442), all inside their ceilings. The aggregate cap moves from 3700 to 3724, the same 24 KB headroom as the re-measurements before it. Raised to 3749 at v1.39.9 on a measured delta. Main measured 3718 KB gz after v1.39.7 and v1.39.8 (unit-aware copy, the phone layouts, the time-zone work and the account-settings restore labels, almost all of it new strings in the seven lazy locale catalogs), leaving 6 KB of the v1.39.4 headroom; the manual workout entry (#1085: the form, its sheet, the delete action and their copy in seven locales) adds about 7 KB and measured 3725. The four eager routes stay inside their drift allowances (/ +12.5, /insights +12.1, /insights/mood +10.2 KB of 20 on main). The aggregate cap moves from 3724 to 3749, the same 24 KB headroom as the re-measurements before it. Raised to 3790 at v1.40.0 on a measured delta. With the background document reads on main (#1103: the run polling hook, the still-reading states and their copy in seven locales), the key backup step and the plain-http sign-in notice (#1104) and the v1.40 fixes (#1106: the duration reader, the marker unit lock, the chart window) each branch measured 3752 KB gz on its own against the 3749 cap; together they add a few KB more. The four eager routes stay inside their drift allowances (/ +18.4, /insights +16.5, /measurements +14.9, /insights/mood +14.7 KB of 20). The aggregate cap moves from 3749 to 3790, about 24 KB above the combined build, the same headroom as the re-measurements before it. Re-based at v1.40.0 when the quick-entry forms began loading on demand. Custom symptoms added a fifth quick-entry form, and with all five forms bundled eagerly every one of the four eager routes measured over its ceiling (/ 472, /insights 457, /insights/mood 468, /measurements 454 KB gz, each 21 to 25 KB over the baseline). Loading the forms on demand brings them to / 429, /insights 368, /insights/mood 379, /measurements 411: 43 to 90 KB less on first load. The four route baselines move down to those measured values, so the gain is protected by the same 20 KB allowance rather than spent. The aggregate moves the other way, 3774 to 4077, for the reason described at v1.37.27: the aggregate counts each emitted chunk once per route group, and on-demand form chunks land in every route group that can open a quick-entry sheet. Bundling the five forms into one shared on-demand module measured the same (4078), so the split is not the cause. The aggregate cap moves from 3790 to 4100. Bytes on disk rose; bytes a reader downloads fell on every eager route. Raised to 4155 at v1.40.2 on a measured delta. The Coach redesign (the right conversation panel, its row menu and the quick-settings overlay, all loaded on demand), the answer action row, the dashboard's Today overview, the document procedure filter and their copy in seven locales took the whole-build sum from 4080 to 4129 KB gz; most of it is new strings in the seven lazy locale catalogs and the on-demand Coach chunks, which a first paint does not load. The four eager routes stay well inside their drift allowances (/ +1.9, /measurements +1.9, /insights/mood +1.4, /insights +1.0 KB of 20). The aggregate cap moves from 4100 to 4155, about 26 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4182 at v1.42.0 on a measured delta. Main measured 4146 KB gz (lazy catalogs 1177, other chunks 2969); the release branch's shared contract change measured 4158 (lazy catalogs 1188, other chunks 2969): the whole 12 KB is new strings in the seven lazy locale catalogs (the Health Connect import card, air quality and pollen, the managed-profile handover and claim, own vaccines, the lab conversion note, the per-type Apple Health list), and no eager route moved (/ +3.6, /measurements +1.5, /insights/mood +1.1, /insights +0.7 KB of 20). The aggregate cap moves from 4155 to 4182, about 24 KB above the build, the same headroom as before; per-route baselines are unchanged. Re-based at v1.42.0 when the settings sections and Zod left the shared bundles. The settings section route imported all twenty-one sections statically; each now loads on its own, and /settings/[section] measured 611 KB gz before and 403 after. The account-access parse that runs on every boot was the shell's only Zod import, about 63 KB gz on every route; it is plain TypeScript now, held to the published schema by an equivalence test, and the layout entry fell from 199 to 136 KB gz. The canonical unit table moved out of the measurement validation module, which took Zod off the routes whose only reason for it was the display transform: /insights 369 to 304, /medications 428 to 365, /labs 400 to 337. Across the 627 client routes 62 fell, none grew by more than 2 KB, and the per-route sum fell by 1844 KB gz. The /insights baseline moves down to 304 and /settings/[section] joins the watched routes at 403, so both gains are held by the same 20 KB allowance rather than spent. The aggregate moves the other way, 4167 to 4311, for the reason described at v1.37.27 and v1.40.0: the aggregate counts each emitted chunk once, and modules that used to sit in one shared chunk are now emitted into each route group that still needs them (the measurement validation module and the unit table into about twenty-five each, the settings sections into their own chunks). Bytes on disk rose; bytes a reader downloads fell on every route that moved. The aggregate cap moves from 4182 to 4335, about 24 KB above the build, the same headroom as before.", + "$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 /insights and /measurements sat within a kilobyte of theirs and / and /insights/mood within one of theirs; e2e run 34043772954 failed on `/insights/page: 445 KB gz exceeds the 445 KB budget` with the other two at 459 of 460, and none of it was a regression anyone had made. A ceiling a route is already touching does not measure the route, it measures whichever dependency bump landed that week. The file states the measured value per route now, plus one drift allowance over it: measured on a local arm64 build at d48bbff01 as / 447, /insights 434, /measurements 433, /insights/mood 447, with a 20 KB allowance, so the effective ceilings are 467 / 454 / 453 / 467 — a raise for all four. The allowance covers the runner delta (the CI runner measures each route a little heavier than a local arm64 build) and ordinary week-to-week drift, while the step change the gate exists for — a statically imported message catalog, a duplicated chart library — is several times larger and still fails. Every run now prints the delta against the baseline, so a route that has crept 15 KB is legible in the log long before it is red; the flat ceiling could only ever say pass or fail. Re-base by re-measuring on a build with nothing else in it and writing the new numbers here with the reason, exactly as a raised ceiling was written before. Raised to 3724 at v1.39.4 on a measured delta, not a feature. The release branch alone measured 3699 KB gz (3698.9) against the 3700 cap, so the ordinary drift room the v1.37.32 raise left had been used up by the releases since; the review fixes on top of it added 1.3 KB gz across the whole build (3700.2), almost all of it new copy in the seven lazy locale catalogs (the total and reused-table labels, the step and chip wording) plus the result-panel states. The four eager routes measured the same on both builds (/ 458, /insights/mood 456, /insights 445, /measurements 442), all inside their ceilings. The aggregate cap moves from 3700 to 3724, the same 24 KB headroom as the re-measurements before it. Raised to 3749 at v1.39.9 on a measured delta. Main measured 3718 KB gz after v1.39.7 and v1.39.8 (unit-aware copy, the phone layouts, the time-zone work and the account-settings restore labels, almost all of it new strings in the seven lazy locale catalogs), leaving 6 KB of the v1.39.4 headroom; the manual workout entry (#1085: the form, its sheet, the delete action and their copy in seven locales) adds about 7 KB and measured 3725. The four eager routes stay inside their drift allowances (/ +12.5, /insights +12.1, /insights/mood +10.2 KB of 20 on main). The aggregate cap moves from 3724 to 3749, the same 24 KB headroom as the re-measurements before it. Raised to 3790 at v1.40.0 on a measured delta. With the background document reads on main (#1103: the run polling hook, the still-reading states and their copy in seven locales), the key backup step and the plain-http sign-in notice (#1104) and the v1.40 fixes (#1106: the duration reader, the marker unit lock, the chart window) each branch measured 3752 KB gz on its own against the 3749 cap; together they add a few KB more. The four eager routes stay inside their drift allowances (/ +18.4, /insights +16.5, /measurements +14.9, /insights/mood +14.7 KB of 20). The aggregate cap moves from 3749 to 3790, about 24 KB above the combined build, the same headroom as the re-measurements before it. Re-based at v1.40.0 when the quick-entry forms began loading on demand. Custom symptoms added a fifth quick-entry form, and with all five forms bundled eagerly every one of the four eager routes measured over its ceiling (/ 472, /insights 457, /insights/mood 468, /measurements 454 KB gz, each 21 to 25 KB over the baseline). Loading the forms on demand brings them to / 429, /insights 368, /insights/mood 379, /measurements 411: 43 to 90 KB less on first load. The four route baselines move down to those measured values, so the gain is protected by the same 20 KB allowance rather than spent. The aggregate moves the other way, 3774 to 4077, for the reason described at v1.37.27: the aggregate counts each emitted chunk once per route group, and on-demand form chunks land in every route group that can open a quick-entry sheet. Bundling the five forms into one shared on-demand module measured the same (4078), so the split is not the cause. The aggregate cap moves from 3790 to 4100. Bytes on disk rose; bytes a reader downloads fell on every eager route. Raised to 4155 at v1.40.2 on a measured delta. The Coach redesign (the right conversation panel, its row menu and the quick-settings overlay, all loaded on demand), the answer action row, the dashboard's Today overview, the document procedure filter and their copy in seven locales took the whole-build sum from 4080 to 4129 KB gz; most of it is new strings in the seven lazy locale catalogs and the on-demand Coach chunks, which a first paint does not load. The four eager routes stay well inside their drift allowances (/ +1.9, /measurements +1.9, /insights/mood +1.4, /insights +1.0 KB of 20). The aggregate cap moves from 4100 to 4155, about 26 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4182 at v1.42.0 on a measured delta. Main measured 4146 KB gz (lazy catalogs 1177, other chunks 2969); the release branch's shared contract change measured 4158 (lazy catalogs 1188, other chunks 2969): the whole 12 KB is new strings in the seven lazy locale catalogs (the Health Connect import card, air quality and pollen, the managed-profile handover and claim, own vaccines, the lab conversion note, the per-type Apple Health list), and no eager route moved (/ +3.6, /measurements +1.5, /insights/mood +1.1, /insights +0.7 KB of 20). The aggregate cap moves from 4155 to 4182, about 24 KB above the build, the same headroom as before; per-route baselines are unchanged. Re-based at v1.42.0 when the settings sections and Zod left the shared bundles. The settings section route imported all twenty-one sections statically; each now loads on its own, and /settings/[section] measured 611 KB gz before and 403 after. The account-access parse that runs on every boot was the shell's only Zod import, about 63 KB gz on every route; it is plain TypeScript now, held to the published schema by an equivalence test, and the layout entry fell from 199 to 136 KB gz. The canonical unit table moved out of the measurement validation module, which took Zod off the routes whose only reason for it was the display transform: /insights 369 to 304, /medications 428 to 365, /labs 400 to 337. Across the 627 client routes 62 fell, none grew by more than 2 KB, and the per-route sum fell by 1844 KB gz. The /insights baseline moves down to 304 and /settings/[section] joins the watched routes at 403, so both gains are held by the same 20 KB allowance rather than spent. The aggregate moves the other way, 4167 to 4311, for the reason described at v1.37.27 and v1.40.0: the aggregate counts each emitted chunk once, and modules that used to sit in one shared chunk are now emitted into each route group that still needs them (the measurement validation module and the unit table into about twenty-five each, the settings sections into their own chunks). Bytes on disk rose; bytes a reader downloads fell on every route that moved. The aggregate cap moves from 4182 to 4335, about 24 KB above the build, the same headroom as before. Raised to 4400 at v1.42.0 on a measured delta. The release branch after its tickets merge measured 4365 KB gz (lazy catalogs 1206, other chunks 3159), already 30 KB over the 4335 cap; the day view and timeline contract adds 10 more (catalogs 1215, other 3160), almost all of it the base copy for the day, the timeline and life events in seven locales. The aggregate cap moves from 4335 to 4400, about 25 KB above the build, the same headroom as before; per-route baselines are unchanged.", "routeBaselineKbGz": { "/page": 429, "/insights/page": 304, @@ -8,6 +8,6 @@ "/settings/[section]/page": 403 }, "routeDriftAllowanceKbGz": 20, - "totalClientKbGz": 4335, + "totalClientKbGz": 4400, "maxRechartsChunks": 1 } diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e5b232871..4c646efd7 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -23295,6 +23295,256 @@ paths: "404": *a65 "422": *a4 "429": *a5 + /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. + parameters: + - in: query + name: from + schema: + type: string + description: A local calendar date, `YYYY-MM-DD`. + required: true + description: A local calendar date, `YYYY-MM-DD`. + - in: query + name: to + schema: + type: string + description: A local calendar date, `YYYY-MM-DD`. + required: true + description: A local calendar date, `YYYY-MM-DD`. + responses: + "200": + description: The days in the window that hold entries. + content: + application/json: + schema: + $ref: "#/components/schemas/DayIndexEnvelope" + "401": *a1 + "403": *a11 + "422": *a4 + "429": *a5 + "501": *a32 + /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." + parameters: + - in: path + name: date + schema: + type: string + description: A local calendar date, `YYYY-MM-DD`. + required: true + description: A local calendar date, `YYYY-MM-DD`. + responses: + "200": + description: The day. + content: + application/json: + schema: + $ref: "#/components/schemas/DayEnvelope" + "401": *a1 + "403": *a11 + "422": *a4 + "429": *a5 + "501": *a32 + /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. + parameters: + - in: query + name: zoom + schema: + default: all + type: string + enum: + - all + - year + - quarter + - in: query + name: from + schema: + type: string + description: A local calendar date, `YYYY-MM-DD`. + - in: query + name: to + schema: + type: string + description: A local calendar date, `YYYY-MM-DD`. + - in: query + name: values + schema: + description: Comma-separated value series to include, as `MeasurementType` names or `MOOD`. + type: string + description: Comma-separated value series to include, as `MeasurementType` names or `MOOD`. + responses: + "200": + description: The timeline for the requested zoom and window. + content: + application/json: + schema: + $ref: "#/components/schemas/TimelineEnvelope" + "401": *a1 + "403": &a66 + description: >- + `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. + + + Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). + Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections + do not reach this surface, and a read grant on a request that writes — the response carries no reason, so it + is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's + audit trail instead. On the cookie transport the same code answers one further case: a session that has + entered a shared record and sends a request without its record-context assertion, which is a client older + than the fence — leave the record and reload. A request that names no record but the caller's own never + reaches this response, so a client that never switches and never sends the per-request account selector will + not see it. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + "501": *a32 + /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: + $ref: "#/components/schemas/TimelineReadinessEnvelope" + "401": *a1 + "403": *a66 + "422": *a4 + "429": *a5 + "501": *a32 + /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: + $ref: "#/components/schemas/LifeEventListEnvelope" + "401": *a1 + "403": *a11 + "422": *a4 + "429": *a5 + "501": *a32 + 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: + $ref: "#/components/schemas/CreateLifeEventRequest" + responses: + "201": + description: Created. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateLifeEventEnvelope" + "401": *a1 + "422": *a4 + "429": *a12 + "501": *a32 + /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). + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateLifeEventRequest" + responses: + "200": + description: Updated. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateLifeEventEnvelope" + "401": *a1 + "404": &a67 + description: No such life event on this record. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + "501": *a32 + delete: + tags: + - Records + summary: Delete a life event + description: Soft-deletes one life event. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteLifeEventEnvelope" + "401": *a1 + "404": *a67 + "422": *a4 + "429": *a5 + "501": *a32 /api/bugreport: post: security: [] @@ -26958,6 +27208,8 @@ components: type: boolean vaccinations: type: boolean + timeline: + type: boolean baseUpdatedAt: description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored @@ -32841,6 +33093,8 @@ components: type: boolean vaccinations: type: boolean + timeline: + type: boolean additionalProperties: false cycleTrackingEnabled: type: boolean @@ -33506,6 +33760,81 @@ components: - type: "null" description: "Partial edit of one occurrence. `note: null` clears the note, `episodeId: null` drops the link. A changed `episodeId` must name an open episode; an unchanged link to an episode that has since resolved is kept." + CreateLifeEventRequest: + type: object + properties: + category: + type: string + enum: + - FAMILY + - HOME + - WORK + - LOSS + - OTHER + startDate: + type: string + description: A local calendar date, `YYYY-MM-DD`. + endDate: + anyOf: + - type: string + description: A local calendar date, `YYYY-MM-DD`. + - type: "null" + precision: + type: string + enum: + - DAY + - MONTH + - YEAR + title: + type: string + minLength: 1 + maxLength: 120 + note: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + required: + - category + - startDate + - precision + - title + additionalProperties: false + UpdateLifeEventRequest: + type: object + properties: + category: + type: string + enum: + - FAMILY + - HOME + - WORK + - LOSS + - OTHER + startDate: + type: string + description: A local calendar date, `YYYY-MM-DD`. + endDate: + anyOf: + - type: string + description: A local calendar date, `YYYY-MM-DD`. + - type: "null" + precision: + type: string + enum: + - DAY + - MONTH + - YEAR + title: + type: string + minLength: 1 + maxLength: 120 + note: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + additionalProperties: false MedicationTreatmentClass: type: string enum: @@ -38404,6 +38733,70 @@ components: - updatedAt - events additionalProperties: {} + lifeEvents: + default: [] + type: array + items: + type: object + properties: + id: + type: string + minLength: 1 + category: + type: string + enum: + - FAMILY + - HOME + - WORK + - LOSS + - OTHER + startDate: + type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + endDate: + anyOf: + - type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + - type: "null" + precision: + type: string + enum: + - DAY + - MONTH + - YEAR + title: + type: string + minLength: 1 + titleEncrypted: + type: string + minLength: 1 + note: + anyOf: + - type: string + - type: "null" + noteEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" + createdAt: + type: string + minLength: 1 + updatedAt: + type: string + minLength: 1 + deletedAt: + anyOf: + - type: string + minLength: 1 + - type: "null" + required: + - category + - startDate + - precision + - createdAt + - updatedAt + additionalProperties: {} allergies: default: [] type: array @@ -41083,6 +41476,7 @@ components: - biomarkers - illnessEpisodes - symptomDefinitions + - lifeEvents - allergies - familyHistory - workouts @@ -50577,6 +50971,9 @@ components: vaccinations: type: boolean description: Whether the "vaccinations" module is enabled for this account. + timeline: + type: boolean + description: Whether the "timeline" module is enabled for this account. required: - cycle - mood @@ -50597,6 +50994,7 @@ components: - mentalHealth - nutrients - vaccinations + - timeline additionalProperties: false description: Fully-resolved module enable/disable map. Every toggleable module key is present. `false` means the surface should disappear end-to-end (nav, dashboard, insights, …). `cycle` mirrors `cycleTrackingEnabled`; `coach` @@ -77294,6 +77692,14 @@ components: - not_granted - unavailable description: Why the "vaccinations" module is or is not available here. + timeline: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "timeline" module is or is not available here. required: - cycle - mood @@ -77314,6 +77720,7 @@ components: - mentalHealth - nutrients - vaccinations + - timeline additionalProperties: false description: "Why each toggleable module is or is not available for the record this session is inside. `enabled` — the module is on and the client may paint it. `disabled` — the record has it switched off. `not_granted` — the @@ -81019,6 +81426,930 @@ components: - data - error additionalProperties: false + DayIndexEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DayIndex" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DayIndex: + type: object + properties: + from: + type: string + description: A local calendar date, `YYYY-MM-DD`. + to: + type: string + description: A local calendar date, `YYYY-MM-DD`. + days: + type: object + propertyNames: + type: string + additionalProperties: + type: array + items: + type: string + enum: + - values + - sleep + - mood + - assessments + - medications + - illness + - symptoms + - allergies + - labs + - visits + - vaccinations + - checkups + - documents + - workouts + - cycle + - environment + - lifestyle + - lifeEvents + description: Each day in the window that holds anything, keyed `YYYY-MM-DD`, with the sections it holds. Days without + entries are absent. + notable: + type: array + items: + type: string + description: Days in the window that carry a notable observation. + required: + - from + - to + - days + - notable + additionalProperties: false + DayEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/Day" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + Day: + type: object + properties: + date: + type: string + description: A local calendar date, `YYYY-MM-DD`. + tz: + type: string + description: The IANA zone the day was cut in. + counts: + type: object + properties: + values: + type: integer + minimum: 0 + maximum: 9007199254740991 + entries: + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - values + - entries + additionalProperties: false + running: + type: array + items: + $ref: "#/components/schemas/DayRunningItem" + values: + type: array + items: + $ref: "#/components/schemas/DayValue" + events: + type: array + items: + $ref: "#/components/schemas/DayEvent" + notable: + type: array + items: + $ref: "#/components/schemas/DayNotable" + sections: + type: object + propertyNames: + type: string + enum: + - values + - sleep + - mood + - assessments + - medications + - illness + - symptoms + - allergies + - labs + - visits + - vaccinations + - checkups + - documents + - workouts + - cycle + - environment + - lifestyle + - lifeEvents + additionalProperties: + type: object + properties: + available: + type: boolean + const: false + reason: + type: string + enum: + - not_shared + - module_disabled + required: + - available + - reason + additionalProperties: false + description: Sections the caller cannot see. A section of a switched-off module is left out entirely rather than listed. + required: + - date + - tz + - counts + - running + - values + - events + - notable + - sections + additionalProperties: false + DayRunningItem: + type: object + properties: + kind: + type: string + enum: + - medication + - medicationCourse + - medicationPause + - illness + - restMode + - cyclePhase + - travel + - lifestyle + - lifeEvent + section: + type: string + enum: + - values + - sleep + - mood + - assessments + - medications + - illness + - symptoms + - allergies + - labs + - visits + - vaccinations + - checkups + - documents + - workouts + - cycle + - environment + - lifestyle + - lifeEvents + id: + type: string + title: + type: string + description: Decrypted server-side for the caller's own session. + sub: + anyOf: + - type: string + - type: "null" + description: A second line, already formatted (a dose, a destination). + since: + type: string + description: A local calendar date, `YYYY-MM-DD`. + until: + anyOf: + - type: string + description: A local calendar date, `YYYY-MM-DD`. + - type: "null" + description: Null while it is still open. + dayIndex: + anyOf: + - type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + - type: "null" + description: Day n of the period, counting `since` as day 1. + dayCount: + anyOf: + - type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + - type: "null" + description: Length of a closed period in days; null while open. + href: + anyOf: + - type: string + - type: "null" + description: In-app path to the record this came from. + required: + - kind + - section + - id + - title + - sub + - since + - until + - dayIndex + - dayCount + - href + additionalProperties: false + DayValue: + type: object + properties: + type: + type: string + description: A `MeasurementType`. + value: + type: number + unit: + type: string + at: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + source: + type: string + description: The `MeasurementSource` of the canonical row. + band: + anyOf: + - type: object + properties: + lo: + type: number + hi: + type: number + n: + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - lo + - hi + - n + additionalProperties: false + - type: "null" + description: The person's own usual range over the 30 days before this day, from their daily values. Null with too + little history. + required: + - type + - value + - unit + - at + - source + - band + additionalProperties: false + DayEvent: + type: object + properties: + at: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + description: The instant, or null for something that has a date only. + kind: + type: string + enum: + - intake + - doseChange + - medicationStart + - medicationEnd + - pauseStart + - pauseEnd + - courseStart + - courseEnd + - illnessOnset + - illnessResolved + - illnessDayLog + - symptom + - allergyOnset + - labResult + - visit + - procedure + - vaccination + - checkup + - document + - mood + - assessment + - workout + - cycleDayLog + - lifeEvent + section: + type: string + enum: + - values + - sleep + - mood + - assessments + - medications + - illness + - symptoms + - allergies + - labs + - visits + - vaccinations + - checkups + - documents + - workouts + - cycle + - environment + - lifestyle + - lifeEvents + id: + type: string + title: + type: string + meta: + anyOf: + - type: string + - type: "null" + note: + anyOf: + - type: string + - type: "null" + description: The person's own note, decrypted for the caller's session. + docs: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + required: + - id + - name + additionalProperties: false + description: Documents filed against the entry. + href: + anyOf: + - type: string + - type: "null" + required: + - at + - kind + - section + - id + - title + - meta + - note + - docs + - href + additionalProperties: false + DayNotable: + type: object + properties: + kind: + type: string + enum: + - extremeHigh + - extremeLow + - firstValue + - gap + type: + anyOf: + - type: string + - type: "null" + description: The `MeasurementType` it is about. + params: + type: object + propertyNames: + type: string + additionalProperties: + anyOf: + - type: string + - type: number + description: 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. + required: + - kind + - type + - params + additionalProperties: false + TimelineEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/Timeline" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + Timeline: + type: object + properties: + zoom: + type: string + enum: + - all + - year + - quarter + range: + type: object + properties: + from: + type: string + description: A local calendar date, `YYYY-MM-DD`. + to: + type: string + description: A local calendar date, `YYYY-MM-DD`. + dataFrom: + anyOf: + - type: string + description: A local calendar date, `YYYY-MM-DD`. + - type: "null" + description: The earliest date anything in the record carries. + required: + - from + - to + - dataFrom + additionalProperties: false + lanes: + type: array + items: + type: object + properties: + key: + type: string + enum: + - life + - illness + - allergies + - medications + - vaccinations + - visits + - labs + - documents + - cycle + items: + type: array + items: + $ref: "#/components/schemas/TimelineItem" + required: + - key + - items + additionalProperties: false + standing: + type: array + items: + $ref: "#/components/schemas/TimelineStandingItem" + description: "Things without a start: shown once as standing." + series: + type: array + items: + $ref: "#/components/schemas/TimelineSeries" + notable: + type: array + items: + type: object + properties: + date: + type: string + description: A local calendar date, `YYYY-MM-DD`. + kind: + type: string + enum: + - extremeHigh + - extremeLow + - firstValue + - gap + required: + - date + - kind + additionalProperties: false + required: + - zoom + - range + - lanes + - standing + - series + - notable + additionalProperties: false + TimelineItem: + type: object + properties: + id: + type: string + kind: + type: string + enum: + - lifeEvent + - travel + - episode + - chronic + - allergy + - medication + - course + - doseChange + - pause + - vaccination + - visit + - procedure + - labDay + - document + - cycle + start: + type: string + description: A local calendar date, `YYYY-MM-DD`. + end: + anyOf: + - type: string + description: A local calendar date, `YYYY-MM-DD`. + - type: "null" + description: Null for a point or an open span. + open: + type: boolean + description: A span that has not ended. + precision: + type: string + enum: + - DAY + - MONTH + - YEAR + startKnown: + type: boolean + description: False when the start is a stand-in (a medication without a start date starts at its first intake). + label: + type: string + sub: + anyOf: + - type: string + - type: "null" + href: + anyOf: + - type: string + - type: "null" + required: + - id + - kind + - start + - end + - open + - precision + - startKnown + - label + - sub + - href + additionalProperties: false + TimelineStandingItem: + type: object + properties: + lane: + type: string + enum: + - life + - illness + - allergies + - medications + - vaccinations + - visits + - labs + - documents + - cycle + id: + type: string + label: + type: string + since: + anyOf: + - type: string + description: A local calendar date, `YYYY-MM-DD`. + - type: "null" + href: + anyOf: + - type: string + - type: "null" + required: + - lane + - id + - label + - since + - href + additionalProperties: false + TimelineSeries: + type: object + properties: + key: + type: string + description: A `MeasurementType` or `MOOD`. + unit: + anyOf: + - type: string + - type: "null" + granularity: + type: string + enum: + - month + - week + - day + points: + type: array + items: + type: object + properties: + t: + type: string + description: A local calendar date, `YYYY-MM-DD`. + mean: + type: number + required: + - t + - mean + additionalProperties: false + required: + - key + - unit + - granularity + - points + additionalProperties: false + TimelineReadinessEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/TimelineReadiness" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + TimelineReadiness: + type: object + properties: + verdict: + type: string + enum: + - carries + - thin + since: + anyOf: + - type: string + description: A local calendar date, `YYYY-MM-DD`. + - type: "null" + description: The earliest date the carrying lanes reach back to. + lanes: + type: array + items: + type: object + properties: + key: + type: string + enum: + - values + - life + - illness + - allergies + - medications + - vaccinations + - visits + - labs + - documents + - cycle + - mood + - environment + status: + type: string + enum: + - carries + - thin + - empty + count: + type: integer + minimum: 0 + maximum: 9007199254740991 + detail: + anyOf: + - type: object + properties: + key: + type: string + params: + type: object + propertyNames: + type: string + additionalProperties: + anyOf: + - type: string + - type: number + description: 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. + required: + - key + - params + additionalProperties: false + - type: "null" + description: Worded by the client from `timeline.readiness.detail`. + gaps: + type: array + items: + type: object + properties: + key: + type: string + count: + type: integer + minimum: 0 + maximum: 9007199254740991 + href: + type: string + required: + - key + - count + - href + additionalProperties: false + required: + - key + - status + - count + - detail + - gaps + additionalProperties: false + required: + - verdict + - since + - lanes + additionalProperties: false + LifeEventListEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/LifeEventList" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + LifeEventList: + type: object + properties: + events: + type: array + items: + $ref: "#/components/schemas/LifeEvent" + required: + - events + additionalProperties: false + LifeEvent: + type: object + properties: + id: + type: string + category: + type: string + enum: + - FAMILY + - HOME + - WORK + - LOSS + - OTHER + startDate: + type: string + description: A local calendar date, `YYYY-MM-DD`. + endDate: + anyOf: + - type: string + description: A local calendar date, `YYYY-MM-DD`. + - type: "null" + precision: + type: string + enum: + - DAY + - MONTH + - YEAR + title: + anyOf: + - type: string + - type: "null" + description: Decrypted; null only when the stored title cannot be read. + note: + anyOf: + - type: string + - type: "null" + createdAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - id + - category + - startDate + - endDate + - precision + - title + - note + - createdAt + - updatedAt + additionalProperties: false + description: A dated anchor in the person's life. Shared under the `profile` domain; never sent to a model. + CreateLifeEventEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/LifeEvent" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UpdateLifeEventEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/LifeEvent" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DeleteLifeEventEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/LifeEvent" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false __schema0: anyOf: - type: string diff --git a/knip.jsonc b/knip.jsonc index 6a01a4198..50a7b35a4 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -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", diff --git a/messages/de.json b/messages/de.json index 96f498f5a..679e77371 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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" + } } } diff --git a/messages/en.json b/messages/en.json index 66d175180..4dfd33018 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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" + } } } diff --git a/messages/es.json b/messages/es.json index 174af5c61..3e9584bcb 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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" + } } } diff --git a/messages/fr.json b/messages/fr.json index 88791634c..ef16ae721 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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" + } } } diff --git a/messages/it.json b/messages/it.json index 5ca435d91..b68c91155 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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" + } } } diff --git a/messages/ko.json b/messages/ko.json index 25350e105..adcb69a77 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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": "연" + } } } diff --git a/messages/pl.json b/messages/pl.json index 6466d1944..f819cb572 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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" + } } } diff --git a/prisma/migrations/0387_life_event/migration.sql b/prisma/migrations/0387_life_event/migration.sql new file mode 100644 index 000000000..785afe466 --- /dev/null +++ b/prisma/migrations/0387_life_event/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 877ce6d27..d7e7119cc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") +} diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 1d6a97db3..acbab8c25 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -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 diff --git a/src/__tests__/i18n-reverse-coverage.test.ts b/src/__tests__/i18n-reverse-coverage.test.ts index 27d9efc4e..f103b10f0 100644 --- a/src/__tests__/i18n-reverse-coverage.test.ts +++ b/src/__tests__/i18n-reverse-coverage.test.ts @@ -80,6 +80,9 @@ const DYNAMIC_ALLOWLIST_PREFIXES = [ const RELEASE_PENDING_PREFIXES: Record = { "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)", }; /** diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index e2177cc3e..ff4bbee9b 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -1007,6 +1007,31 @@ const DELEGABLE_ROUTES: Record = { 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 = { * 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` — diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index 54a0b6bb9..64d8ba407 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -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 = [ // 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 diff --git a/src/app/api/day/[date]/route.ts b/src/app/api/day/[date]/route.ts new file mode 100644 index 000000000..f1ef52ff0 --- /dev/null +++ b/src/app/api/day/[date]/route.ts @@ -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(); +}); diff --git a/src/app/api/day/index/route.ts b/src/app/api/day/index/route.ts new file mode 100644 index 000000000..c922b291d --- /dev/null +++ b/src/app/api/day/index/route.ts @@ -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(); +}); diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index 392263eff..66418fa75 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -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( diff --git a/src/app/api/export/__tests__/soft-delete-filter.test.ts b/src/app/api/export/__tests__/soft-delete-filter.test.ts index 0e04c6bba..f2dcb8364 100644 --- a/src/app/api/export/__tests__/soft-delete-filter.test.ts +++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts @@ -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" } }), diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index 8c01d3a7b..27e422831 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -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([]) }, diff --git a/src/app/api/life-events/[id]/route.ts b/src/app/api/life-events/[id]/route.ts new file mode 100644 index 000000000..ce076032e --- /dev/null +++ b/src/app/api/life-events/[id]/route.ts @@ -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(); +}); diff --git a/src/app/api/life-events/route.ts b/src/app/api/life-events/route.ts new file mode 100644 index 000000000..3478c7af4 --- /dev/null +++ b/src/app/api/life-events/route.ts @@ -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(); +}); diff --git a/src/app/api/timeline/readiness/route.ts b/src/app/api/timeline/readiness/route.ts new file mode 100644 index 000000000..4113a845b --- /dev/null +++ b/src/app/api/timeline/readiness/route.ts @@ -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(); +}); diff --git a/src/app/api/timeline/route.ts b/src/app/api/timeline/route.ts new file mode 100644 index 000000000..cfc16d284 --- /dev/null +++ b/src/app/api/timeline/route.ts @@ -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(); +}); diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts index 0bbccd864..cba0069f4 100644 --- a/src/components/layout/nav-model.ts +++ b/src/components/layout/nav-model.ts @@ -6,6 +6,7 @@ import { Droplets, FileScan, FlaskConical, + History, Home, Lightbulb, MessagesSquare, @@ -206,6 +207,18 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ 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. // diff --git a/src/components/settings/modules-section.tsx b/src/components/settings/modules-section.tsx index f21a8efa2..c8edb0980 100644 --- a/src/components/settings/modules-section.tsx +++ b/src/components/settings/modules-section.tsx @@ -46,6 +46,7 @@ import { Dumbbell, FileScan, FileText, + History, MessageCircleHeart, Leaf, Moon, @@ -117,6 +118,8 @@ const MODULE_ICONS: Record = { nutrients: Leaf, // v1.38.0 — the immunization log. vaccinations: Syringe, + // v1.42 — the life timeline (opt-in). + timeline: History, }; export function ModulesSection() { diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index aa91c66fc..93c5ae7a8 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -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 = new Set([ "InsightStatusCache", "IntegrationStatus", "LabResult", + "LifeEvent", "Measurement", "MedicationCategoryLabel", "MedicationCourse", diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 3becb2ead..03c7e4fab 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -144,6 +144,7 @@ function moduleMap( mentalHealth: true, nutrients: true, vaccinations: true, + timeline: true, }; return { ...base, ...overrides }; } diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 8903e3986..cf42c62c8 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -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", diff --git a/src/lib/day/__tests__/wire-schemas.test.ts b/src/lib/day/__tests__/wire-schemas.test.ts new file mode 100644 index 000000000..fb09f5211 --- /dev/null +++ b/src/lib/day/__tests__/wire-schemas.test.ts @@ -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); + }); +}); diff --git a/src/lib/day/contract.ts b/src/lib/day/contract.ts new file mode 100644 index 000000000..b92875565 --- /dev/null +++ b/src/lib/day/contract.ts @@ -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:`) 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.` 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:`) 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; +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; + +/** `GET /api/timeline` query and `data`. */ +export type TimelineQuery = z.infer; +export type TimelineResponse = z.infer; +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; +export type LifeEventListResponse = z.infer; +export type LifeEventCreateInput = z.infer; +export type LifeEventUpdateInput = z.infer; diff --git a/src/lib/day/wire-schemas.ts b/src/lib/day/wire-schemas.ts new file mode 100644 index 000000000..62a004820 --- /dev/null +++ b/src/lib/day/wire-schemas.ts @@ -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() }); diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 61402aedb..c20bb470d 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -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. diff --git a/src/lib/export/__tests__/restore-backup-round-trip.test.ts b/src/lib/export/__tests__/restore-backup-round-trip.test.ts index 39a42b987..a593f7d0b 100644 --- a/src/lib/export/__tests__/restore-backup-round-trip.test.ts +++ b/src/lib/export/__tests__/restore-backup-round-trip.test.ts @@ -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([ { diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index bc5f46dab..5aa151725 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -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", diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 2b4b056db..a560f3162 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -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), }, }; } diff --git a/src/lib/export/life-events-backup.ts b/src/lib/export/life-events-backup.ts new file mode 100644 index 000000000..9f373dab3 --- /dev/null +++ b/src/lib/export/life-events-backup.ts @@ -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, + userId: string, + options: LifeEventsBackupOptions = {}, +): Promise { + 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 { + 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 { + 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 }; +} diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index 9dc633e95..46442bc3b 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -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, diff --git a/src/lib/modules/__tests__/gate.test.ts b/src/lib/modules/__tests__/gate.test.ts index b9e665bd9..1dd467853 100644 --- a/src/lib/modules/__tests__/gate.test.ts +++ b/src/lib/modules/__tests__/gate.test.ts @@ -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) { diff --git a/src/lib/modules/__tests__/surface-module-registry.test.ts b/src/lib/modules/__tests__/surface-module-registry.test.ts index e72a1f1bd..927694c35 100644 --- a/src/lib/modules/__tests__/surface-module-registry.test.ts +++ b/src/lib/modules/__tests__/surface-module-registry.test.ts @@ -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(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(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([ ...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); }); diff --git a/src/lib/modules/domain-data.ts b/src/lib/modules/domain-data.ts index cadb16efd..66c941250 100644 --- a/src/lib/modules/domain-data.ts +++ b/src/lib/modules/domain-data.ts @@ -44,6 +44,7 @@ type DomainDataClient = Pick< | "mentalHealthAssessment" | "nutrientIntakeDay" | "vaccinationRecord" + | "lifeEvent" >; type DomainProbe = ( @@ -201,6 +202,15 @@ const MODULE_DOMAIN_PROBES: Record = { 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 }, + }), + ), }; /** diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts index 7fc3f4cf4..4706f06df 100644 --- a/src/lib/modules/registry.ts +++ b/src/lib/modules/registry.ts @@ -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> = // 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 = new Set(MODULE_KEYS); diff --git a/src/lib/modules/surface.ts b/src/lib/modules/surface.ts index 3b83cb9b3..b418a7afe 100644 --- a/src/lib/modules/surface.ts +++ b/src/lib/modules/surface.ts @@ -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; /** A surface id the map declares literally. */ diff --git a/src/lib/navigation/shared-record.ts b/src/lib/navigation/shared-record.ts index 00733d7b7..6b961a155 100644 --- a/src/lib/navigation/shared-record.ts +++ b/src/lib/navigation/shared-record.ts @@ -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; diff --git a/src/lib/openapi/routes/day.ts b/src/lib/openapi/routes/day.ts new file mode 100644 index 000000000..9bbcd74b3 --- /dev/null +++ b/src/lib/openapi/routes/day.ts @@ -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 = { + "/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, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 350bc02ba..03be40829 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -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 = { // 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). // diff --git a/src/lib/query-keys/day.ts b/src/lib/query-keys/day.ts new file mode 100644 index 000000000..778c91f6f --- /dev/null +++ b/src/lib/query-keys/day.ts @@ -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, +}; diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index 69bb9483f..b5397e505 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -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(), ]; /** diff --git a/src/lib/query-keys/timeline.ts b/src/lib/query-keys/timeline.ts new file mode 100644 index 000000000..10603b4e7 --- /dev/null +++ b/src/lib/query-keys/timeline.ts @@ -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, +}; diff --git a/src/lib/sharing/module-disclosure.ts b/src/lib/sharing/module-disclosure.ts index 13e27d2df..f8ad0ca83 100644 --- a/src/lib/sharing/module-disclosure.ts +++ b/src/lib/sharing/module-disclosure.ts @@ -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. */ diff --git a/src/lib/sharing/scope.ts b/src/lib/sharing/scope.ts index 5ea941070..e651885a7 100644 --- a/src/lib/sharing/scope.ts +++ b/src/lib/sharing/scope.ts @@ -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. */ diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index eb9069d3c..d0595406c 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -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([]), diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 42ee942b2..7b1a32565 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -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 { }, }); + // 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`, diff --git a/tests/integration/doctor-report-dense-data.test.ts b/tests/integration/doctor-report-dense-data.test.ts index fac0f7a13..eb8b15caf 100644 --- a/tests/integration/doctor-report-dense-data.test.ts +++ b/tests/integration/doctor-report-dense-data.test.ts @@ -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. */ diff --git a/tests/integration/doctor-report-pulse-hourly-mean.test.ts b/tests/integration/doctor-report-pulse-hourly-mean.test.ts index 050a10dd3..e59d52d76 100644 --- a/tests/integration/doctor-report-pulse-hourly-mean.test.ts +++ b/tests/integration/doctor-report-pulse-hourly-mean.test.ts @@ -43,6 +43,7 @@ const MODULES = { mentalHealth: true, nutrients: true, vaccinations: true, + timeline: true, } as const; const SELECTION = selectionFromLeaves(["PULSE"]); diff --git a/tests/integration/doctor-report-sections.test.ts b/tests/integration/doctor-report-sections.test.ts index 3cbbbb93a..8e8cc2eec 100644 --- a/tests/integration/doctor-report-sections.test.ts +++ b/tests/integration/doctor-report-sections.test.ts @@ -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 () => {