feat: lay down the day view and timeline contract

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

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

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

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

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

No files matched your search

@@ -80,6 +80,9 @@ const DYNAMIC_ALLOWLIST_PREFIXES = [
const RELEASE_PENDING_PREFIXES: Record<string, string> = {
"vaccinations.custom": "v1.42 user-defined vaccines (#1005)",
"labs.convertedFrom": "v1.42 lab unit conversion note (#1095)",
day: "v1.42 day view and its entry points (#613)",
timeline: "v1.42 timeline and its readiness inventory (#613)",
lifeEvents: "v1.42 life-event form and list (#613)",
};
/**
+32 -1
View File
@@ -1007,6 +1007,31 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "illness",
why: "One symptom occurrence of the record, fetch-then-guard against the resolved user. Reached only through this module's MANAGE arm.",
},
// v1.42 (#613) — the day, the day index, the timeline and its readiness
// are reads across every section, so they declare the whole record, the
// posture of the dashboard summary: a full-record grant reads them, a
// scoped grant never does. Narrowing them to the grant's own sections is a
// change of admission, not of this line, and comes back here for review.
"app/api/day/[date]/route.ts": {
domain: "record",
why: "One local day across the record: what ran through it, the readings in its window, what happened on it. Read-only, every row scoped to the resolved user, decrypted only for the session that resolved it. Life events ride along under `profile`, which a full-record grant covers.",
},
"app/api/day/index/route.ts": {
domain: "record",
why: "Which days of a bounded window hold anything, by section key. Dates and section names only, no values and no free text; every read scoped to the resolved user.",
},
"app/api/timeline/route.ts": {
domain: "record",
why: "The record over the years: spans and points from every section plus monthly means. Read-only, scoped to the resolved user, module-gated on the record's own `timeline` switch.",
},
"app/api/timeline/readiness/route.ts": {
domain: "record",
why: "Counts per timeline lane with an in-app link per gap. Counts only, no record content; scoped to the resolved user and module-gated like the timeline.",
},
"app/api/life-events/route.ts": {
domain: "profile",
why: "The record's life events, a section of health background beside allergies and visits; the consent copy for `profile` names them. The GET reads rows scoped `userId: user.id` and decrypts title and note for the resolved session. The create beside it keeps `requireAuth()` until it files an audit row and joins the write literal.",
},
"app/api/workouts/route.ts": {
domain: "measurements",
why: "The record's workout list. Verified to carry no AI-written paragraph: the single-workout route beside it does, and stays refused for exactly that reason.",
@@ -2010,8 +2035,14 @@ const ACTOR_ROUTES: Record<string, string> = {
* list, the occurrence create on the write literal, and the definition create,
* the definition edit/delete and the occurrence edit/delete on the manage
* literal. 232 -> 242 with both.
*
* v1.42 -- the day view and the timeline add five reads on the record list:
* the day, the day index, the timeline, its readiness inventory (all four
* across the whole record) and the life-event list in `profile`. The life-event
* create, edit and delete stay owner-only until they carry their audit rows.
* 242 -> 247.
*/
const FROZEN_ENTRY_COUNT = 242;
const FROZEN_ENTRY_COUNT = 247;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -193,6 +193,11 @@ const MODULE_ROUTE_TREES_BY_KEY: Readonly<
// data-layer-gated: every `/api/vaccinations/*` route is raw CRUD over the
// person's own doses, so all six are EXEMPT below under the same reasoning.
vaccinations: ["src/app/api/vaccinations"],
// v1.42 (#613) — the life timeline. The timeline reads and the readiness
// inventory gate on `timeline`; the life-event row store beside them is
// EXEMPT below under the data-layer reasoning. The day view is not this
// module's: it is core and lives under `/api/day`, which no key owns.
timeline: ["src/app/api/timeline", "src/app/api/life-events"],
};
/** Every declared tree, de-duplicated — the set this inventory walks. */
@@ -413,6 +418,14 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
// while the nav entry, the picker and the report leaf hide.
"src/app/api/vaccinations/[id]/booster/route.ts",
"src/app/api/vaccinations/suggest/route.ts",
// ── DATA LAYER (timeline) ─────────────────────────────────────────
// v1.42 (#613) — the record's life events. Raw CRUD over the person's own
// rows: the module decides whether they SURFACE (the timeline, the day's
// life-event line, the capture entry), not whether the store accepts them.
// A restore must bring them back with the module off, and re-enabling must
// find every event where it was.
"src/app/api/life-events/route.ts",
"src/app/api/life-events/[id]/route.ts",
// ── INFRA / CREDENTIALS (mcp) ─────────────────────────────────────
// The remote MCP endpoint itself is `src/app/mcp/route.ts`, outside `/api`,
// and it carries the gate: an account with the module off gets a 404 there
+22
View File
@@ -0,0 +1,22 @@
/**
* `GET /api/day/{date}` — one local day across the record (v1.42, #613): what
* ran through it, the readings in its window, what happened on it, and the
* few deterministic observations worth a second look. The shape is
* `DayResponse` in `src/lib/day/contract.ts`.
*
* Core, not a module: the day opens for every account. Sections of a
* switched-off module are left out; a section the caller's grant does not
* cover is named in `sections` and not read.
*
* Contract stub: answers 501 behind its admission. It declares the whole
* record for now, the posture every cross-section read in the tree has; a
* scoped grant is refused until the loader narrows each section to the
* grant's domains and the admission is reviewed for that.
*/
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
export const GET = apiHandler(async () => {
await requireRecordAuth("read", "record");
return notImplemented();
});
+16
View File
@@ -0,0 +1,16 @@
/**
* `GET /api/day/index?from=&to=` — which days in a window hold anything, and
* which of them carry a notable observation (v1.42, #613). Feeds the row of
* day dots under a chart and the day links in lists; at most
* `DAY_INDEX_MAX_SPAN_DAYS` days per call. The shape is `DayIndexResponse` in
* `src/lib/day/contract.ts`.
*
* Contract stub: answers 501 behind the same admission as the day itself.
*/
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
export const GET = apiHandler(async () => {
await requireRecordAuth("read", "record");
return notImplemented();
});
@@ -75,6 +75,7 @@ vi.mock("@/lib/db", () => ({
extractedFact: { findMany: vi.fn().mockResolvedValue([]) },
ecgRecording: { findMany: vi.fn().mockResolvedValue([]) },
symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) },
lifeEvent: { findMany: vi.fn().mockResolvedValue([]) },
// v1.15.0 — cycle tables read by the full-backup helper.
cycleProfile: { findUnique: vi.fn() },
menstrualCycle: { findMany: vi.fn() },
@@ -171,6 +172,7 @@ beforeEach(() => {
vi.mocked(prisma.coachReminder.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.ecgRecording.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.symptomDefinition.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.lifeEvent.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodTagCategory.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodTagHidden.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.mentalHealthAssessment.findMany).mockResolvedValue(
@@ -76,6 +76,7 @@ vi.mock("@/lib/db", () => ({
extractedFact: { findMany: vi.fn() },
ecgRecording: { findMany: vi.fn() },
symptomDefinition: { findMany: vi.fn() },
lifeEvent: { findMany: vi.fn() },
onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) },
environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) },
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
@@ -213,6 +214,7 @@ beforeEach(() => {
vi.mocked(prisma.extractedFact.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.ecgRecording.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.symptomDefinition.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.lifeEvent.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userAchievement.findMany).mockResolvedValue([] as never);
});
@@ -275,6 +277,12 @@ describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
where: { userId: "user-1", deletedAt: null },
}),
);
// v1.42 — a deleted life event stays out of a portable file.
expect(prisma.lifeEvent.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "user-1", deletedAt: null },
}),
);
// Biomarker + Workout carry no soft-delete tombstone — userId-only scope.
expect(prisma.biomarker.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { userId: "user-1" } }),
@@ -67,6 +67,7 @@ vi.mock("@/lib/db", () => ({
extractedFact: { findMany: vi.fn().mockResolvedValue([]) },
ecgRecording: { findMany: vi.fn().mockResolvedValue([]) },
symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) },
lifeEvent: { findMany: vi.fn().mockResolvedValue([]) },
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
+21
View File
@@ -0,0 +1,21 @@
/**
* `PATCH /api/life-events/{id}` edits one life event; `DELETE` soft-deletes
* it (v1.42, #613). Record-scoped like the list beside it.
*
* Contract stub: owner-only until implemented, then answers 501. The
* finished routes resolve the record through `requireRecordAuth` and join
* the sharing guard's delegable lists with their audit rows; a stub cannot
* carry those, so it does not claim the admission.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
export const PATCH = apiHandler(async () => {
await requireAuth();
return notImplemented();
});
export const DELETE = apiHandler(async () => {
await requireAuth();
return notImplemented();
});
+26
View File
@@ -0,0 +1,26 @@
/**
* `GET /api/life-events` lists the record's life events, oldest first;
* `POST` adds one (v1.42, #613). Title and note are encrypted at rest and
* never reach a model. Shapes and request schemas in `src/lib/day/`.
*
* Part of the `profile` sharing domain, beside allergies, visits and the
* immunization history. The row store answers whether or not the `timeline`
* module is on, the data-layer posture of the other record tables, so a
* restore keeps working and re-enabling finds the events intact.
*
* Contract stub: answers 501. The read declares its final admission; the
* create stays owner-only until it files its rows through the audit trail
* and joins the delegable write list.
*/
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
export const GET = apiHandler(async () => {
await requireRecordAuth("read", "profile");
return notImplemented();
});
export const POST = apiHandler(async () => {
await requireAuth();
return notImplemented();
});
+18
View File
@@ -0,0 +1,18 @@
/**
* `GET /api/timeline/readiness` — what the timeline can already show, lane by
* lane, with one link per gap (v1.42, #613). Counts and keys, never a score.
* The shape is `TimelineReadinessResponse` in `src/lib/day/contract.ts`.
*
* Gated on the opt-in `timeline` module. Contract stub: answers 501 behind
* its admission and the gate.
*/
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { requireModuleEnabled } from "@/lib/modules/gate";
export const GET = apiHandler(async () => {
const { user } = await requireRecordAuth("read", "record");
const gate = await requireModuleEnabled(user.id, "timeline");
if (!gate.enabled) return gate.response;
return notImplemented();
});
+20
View File
@@ -0,0 +1,20 @@
/**
* `GET /api/timeline?zoom=&from=&to=&values=` — the record laid out over the
* years (v1.42, #613): lanes of conditions, medications, visits, documents and
* life events, the standing items without a start, and monthly value series.
* The shape is `TimelineResponse` in `src/lib/day/contract.ts`.
*
* Gated on the opt-in `timeline` module. Contract stub: answers 501 behind
* its admission and the gate. Like the day, it declares the whole record
* until each lane is narrowed to the grant's domains.
*/
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { requireModuleEnabled } from "@/lib/modules/gate";
export const GET = apiHandler(async () => {
const { user } = await requireRecordAuth("read", "record");
const gate = await requireModuleEnabled(user.id, "timeline");
if (!gate.enabled) return gate.response;
return notImplemented();
});
+13
View File
@@ -6,6 +6,7 @@ import {
Droplets,
FileScan,
FlaskConical,
History,
Home,
Lightbulb,
MessagesSquare,
@@ -206,6 +207,18 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
icon: FileScan,
tourId: "nav-documents",
},
// v1.42 (#613) — the life timeline, after Documents and before Insights.
// Owned by the opt-in `timeline` module in the surface map, so it appears
// only once the person switches it on; on a phone it lands in the More hub
// and takes no primary slot. A record read across sections: a delegate
// holding the whole record sees it, a scoped grant never does.
{
href: "/timeline",
sharedRecord: true,
tKey: "nav.timeline",
icon: History,
tourId: "nav-timeline",
},
// Insights belongs to no module: the `insights` key means AI analysis, and
// the area is data, so switching AI analysis off leaves this entry standing.
//
@@ -46,6 +46,7 @@ import {
Dumbbell,
FileScan,
FileText,
History,
MessageCircleHeart,
Leaf,
Moon,
@@ -117,6 +118,8 @@ const MODULE_ICONS: Record<ModuleKey, LucideIcon> = {
nutrients: Leaf,
// v1.38.0 — the immunization log.
vaccinations: Syringe,
// v1.42 — the life timeline (opt-in).
timeline: History,
};
export function ModulesSection() {
+4
View File
@@ -324,6 +324,9 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
// v1.40 — the person's own symptom names and the note on one occurrence.
{ model: "SymptomDefinition", field: "labelEncrypted", kind: "bytes" },
{ model: "SymptomEvent", field: "noteEncrypted", kind: "bytes" },
// v1.42 — a life event's title and note, in the person's own words.
{ model: "LifeEvent", field: "titleEncrypted", kind: "bytes" },
{ model: "LifeEvent", field: "noteEncrypted", kind: "bytes" },
// ───── v1.19.0 ECG waveform (Bytes column) ─────
{ model: "EcgRecording", field: "waveformEncrypted", kind: "bytes" },
@@ -598,6 +601,7 @@ export const UPDATED_AT_MODELS: ReadonlySet<string> = new Set([
"InsightStatusCache",
"IntegrationStatus",
"LabResult",
"LifeEvent",
"Measurement",
"MedicationCategoryLabel",
"MedicationCourse",
@@ -144,6 +144,7 @@ function moduleMap(
mentalHealth: true,
nutrients: true,
vaccinations: true,
timeline: true,
};
return { ...base, ...overrides };
}
+2
View File
@@ -133,6 +133,8 @@ export const WIPE_MODELS = [
"IllnessDayLog",
// Before its definition and before the episode it may point at (SetNull).
"SymptomEvent",
// v1.42 — the person's life events. Nothing points at them.
"LifeEvent",
"EcgRecording",
"NutrientIntakeDay",
+128
View File
@@ -0,0 +1,128 @@
/**
* The day/timeline contract's own rules (v1.42, #613): the enums mirror the
* database, the life-event dates line up with their precision, and the day
* index refuses a window wider than it promises to answer.
*/
import { describe, expect, it } from "vitest";
import {
LifeEventCategory,
LifeEventPrecision,
} from "@/generated/prisma/enums";
import {
DAY_INDEX_MAX_SPAN_DAYS,
DAY_SECTION_KEYS,
LIFE_EVENT_CATEGORIES,
LIFE_EVENT_PRECISIONS,
MODEL_EXCLUDED_DAY_SECTIONS,
} from "../contract";
import {
dayIndexQuerySchema,
dayPathSchema,
lifeEventCreateSchema,
lifeEventUpdateSchema,
} from "../wire-schemas";
const event = {
category: "HOME",
startDate: "2023-09-01",
precision: "MONTH",
title: "Moved",
} as const;
describe("the contract mirrors the schema", () => {
it("lists exactly the database's life-event categories and precisions", () => {
expect([...LIFE_EVENT_CATEGORIES].sort()).toEqual(
Object.values(LifeEventCategory).sort(),
);
expect([...LIFE_EVENT_PRECISIONS].sort()).toEqual(
Object.values(LifeEventPrecision).sort(),
);
});
it("keeps life events out of what a model sees", () => {
expect(MODEL_EXCLUDED_DAY_SECTIONS).toContain("lifeEvents");
for (const key of MODEL_EXCLUDED_DAY_SECTIONS) {
expect(DAY_SECTION_KEYS).toContain(key);
}
});
});
describe("dates", () => {
it("takes a real calendar date and refuses one that overflows", () => {
expect(dayPathSchema.safeParse({ date: "2026-03-29" }).success).toBe(true);
expect(dayPathSchema.safeParse({ date: "2026-02-30" }).success).toBe(false);
expect(dayPathSchema.safeParse({ date: "2026-3-9" }).success).toBe(false);
});
it("answers a day index of at most the promised span", () => {
const from = "2026-01-01";
const last = new Date(Date.parse(`${from}T00:00:00Z`));
last.setUTCDate(last.getUTCDate() + DAY_INDEX_MAX_SPAN_DAYS - 1);
const to = last.toISOString().slice(0, 10);
expect(dayIndexQuerySchema.safeParse({ from, to }).success).toBe(true);
last.setUTCDate(last.getUTCDate() + 1);
expect(
dayIndexQuerySchema.safeParse({
from,
to: last.toISOString().slice(0, 10),
}).success,
).toBe(false);
expect(
dayIndexQuerySchema.safeParse({ from: "2026-02-02", to: "2026-02-01" })
.success,
).toBe(false);
});
});
describe("a life event", () => {
it("accepts a month-precise date on the first of the month", () => {
expect(lifeEventCreateSchema.safeParse(event).success).toBe(true);
});
it("refuses a date that does not line up with its precision", () => {
expect(
lifeEventCreateSchema.safeParse({ ...event, startDate: "2023-09-14" })
.success,
).toBe(false);
expect(
lifeEventCreateSchema.safeParse({
...event,
precision: "YEAR",
startDate: "2023-09-01",
}).success,
).toBe(false);
expect(
lifeEventCreateSchema.safeParse({
...event,
precision: "YEAR",
startDate: "2019-01-01",
}).success,
).toBe(true);
});
it("refuses an end before its start", () => {
expect(
lifeEventCreateSchema.safeParse({
...event,
precision: "DAY",
startDate: "2026-03-02",
endDate: "2026-03-01",
}).success,
).toBe(false);
});
it("refuses an empty title and an unknown key", () => {
expect(
lifeEventCreateSchema.safeParse({ ...event, title: " " }).success,
).toBe(false);
expect(
lifeEventCreateSchema.safeParse({ ...event, userId: "someone-else" })
.success,
).toBe(false);
expect(
lifeEventUpdateSchema.safeParse({ userId: "someone-else" }).success,
).toBe(false);
});
});
+362
View File
@@ -0,0 +1,362 @@
/**
* The day view and the timeline: the contract every part of v1.42 builds
* against (#613).
*
* One file, so the server that answers, the web views that render and the
* native client that decodes agree on one set of names. Constants and types
* only, no Zod and no server import: the navigation chrome and the surface map
* import from here, and neither may pull a validator into the client bundle.
* The request and response schemas live beside it in `./wire-schemas.ts`, and
* every response type below is inferred from them, so the two cannot drift.
*
* Five route families speak it:
*
* GET /api/day/{date} one local day across the record
* GET /api/day/index which days hold anything, for a window
* GET /api/timeline lanes, standing items and value series
* GET /api/timeline/readiness what the timeline can already show
* GET /api/life-events the record's life events
* POST /api/life-events
* PATCH /api/life-events/{id}
* DELETE /api/life-events/{id}
*
* Rules the shapes carry rather than restate:
*
* - A day is the local calendar day in the record's time zone, never a UTC
* slice. The day route reads the rows live; the rollups cut at UTC.
* - The server decides and the clients render. Nothing here asks a client to
* recompute a band, a running-day count or a verdict. `notable` and the
* readiness details are keys with parameters; the wording lives in the
* message bundles, never in a model.
* - A section of a switched-off module is absent, not empty. A section the
* caller's grant does not cover is named in `sections` with
* `not_shared`, so the view can say so once.
* - Life events are part of the `profile` sharing domain and never reach a
* model: the model-facing projection of a day drops
* {@link MODEL_EXCLUDED_DAY_SECTIONS}.
*/
import type { z } from "zod/v4";
import type {
dayIndexResponseSchema,
dayResponseSchema,
lifeEventCreateSchema,
lifeEventListResponseSchema,
lifeEventSchema,
lifeEventUpdateSchema,
timelineQuerySchema,
timelineReadinessResponseSchema,
timelineResponseSchema,
} from "./wire-schemas";
/* ─── Calendar dates ──────────────────────────────────────────────────────── */
/**
* A local calendar date, `YYYY-MM-DD`. The `?day=` parameter, the day route's
* path segment and every date-only field below use it.
*/
export type DateKey = string;
/** The URL parameter that opens the day layer over any page. */
export const DAY_QUERY_PARAM = "day";
/** Widest window `GET /api/day/index` answers, in days, both ends included. */
export const DAY_INDEX_MAX_SPAN_DAYS = 366;
/* ─── Day sections ────────────────────────────────────────────────────────── */
/**
* The sections of a day, in the order the view shows them. Each one is a
* surface in `src/lib/modules/surface.ts` (`day-section:<key>`) when a module
* owns it; a section without an owner is part of the core record.
*
* values readings in the local day window, canonical source per type
* sleep the night that ended on this morning
* mood mood entries for the date
* assessments mental-health screeners taken that day
* medications intakes, dose changes, pauses, course starts and ends
* illness episodes running or starting, their day logs
* symptoms occurrences of person-defined symptoms
* allergies an allergy whose onset is this day
* labs results drawn that day
* visits visits and procedures
* vaccinations doses given that day
* checkups preventive check-ups completed that day
* documents documents dated that day
* workouts sessions started that day
* cycle the cycle phase and the day log
* environment the day's environment row and a travel period
* lifestyle anamnesis facts valid on that day (shift work, …)
* lifeEvents life events dated that day at DAY precision, or spanning it
*/
export const DAY_SECTION_KEYS = [
"values",
"sleep",
"mood",
"assessments",
"medications",
"illness",
"symptoms",
"allergies",
"labs",
"visits",
"vaccinations",
"checkups",
"documents",
"workouts",
"cycle",
"environment",
"lifestyle",
"lifeEvents",
] as const;
export type DaySectionKey = (typeof DAY_SECTION_KEYS)[number];
/**
* Sections a model never sees, whatever it asks for. The Coach and the MCP
* endpoint read a day through `get_day`, and that projection drops these
* before anything leaves the instance (v1.42: life events go to no model).
*/
export const MODEL_EXCLUDED_DAY_SECTIONS = [
"lifeEvents",
] as const satisfies readonly DaySectionKey[];
/** Why a section is missing from a day the caller can otherwise read. */
export const DAY_SECTION_UNAVAILABLE_REASONS = [
/** The grant the caller reads under does not cover the section's domain. */
"not_shared",
/**
* The owning module is off. Only the model-facing projection (`get_day`)
* reports it, as `{ present: false, reason: "module_disabled" }`; the web
* and native views omit the section without a word.
*/
"module_disabled",
] as const;
export type DaySectionUnavailableReason =
(typeof DAY_SECTION_UNAVAILABLE_REASONS)[number];
/** Something that runs through the day rather than happening on it. */
export const DAY_RUNNING_KINDS = [
"medication",
"medicationCourse",
"medicationPause",
"illness",
"restMode",
"cyclePhase",
"travel",
"lifestyle",
"lifeEvent",
] as const;
export type DayRunningKind = (typeof DAY_RUNNING_KINDS)[number];
/** Something that happened on the day, at a time or on the date. */
export const DAY_EVENT_KINDS = [
"intake",
"doseChange",
"medicationStart",
"medicationEnd",
"pauseStart",
"pauseEnd",
"courseStart",
"courseEnd",
"illnessOnset",
"illnessResolved",
"illnessDayLog",
"symptom",
"allergyOnset",
"labResult",
"visit",
"procedure",
"vaccination",
"checkup",
"document",
"mood",
"assessment",
"workout",
"cycleDayLog",
"lifeEvent",
] as const;
export type DayEventKind = (typeof DAY_EVENT_KINDS)[number];
/**
* Deterministic, descriptive observations. No cause, no verdict: "highest
* morning value since March 2025", never "because of". The client words them
* from `day.notable.<kind>` with the parameters the server sends.
*
* extremeHigh / extremeLow highest or lowest daily value of a type for at
* least three months, with at least 30 days of
* history (`params.since`, `params.value`)
* firstValue the first reading of a type ever (`params.type`)
* gap a type that came at least three times a week
* and then stopped for 14 days (`params.days`);
* only in the visit preparation and readiness
*/
export const DAY_NOTABLE_KINDS = [
"extremeHigh",
"extremeLow",
"firstValue",
"gap",
] as const;
export type DayNotableKind = (typeof DAY_NOTABLE_KINDS)[number];
/* ─── Timeline ────────────────────────────────────────────────────────────── */
/** Zoom levels. `all` bundles hardest: no symptoms, no single intakes. */
export const TIMELINE_ZOOMS = ["all", "year", "quarter"] as const;
export type TimelineZoom = (typeof TIMELINE_ZOOMS)[number];
/**
* The lanes, top to bottom. An empty lane is not sent. Each lane a module
* owns is a surface (`timeline-lane:<key>`) and disappears with it.
*
* life life events, and travel periods from the environment module
* illness episodes, chronic conditions as open lines
* allergies allergies with an onset; the rest are `standing`
* medications medications and courses, dose marks, pauses as gaps
* vaccinations doses
* visits visits and procedures
* labs days with results
* documents dated documents
* cycle cycles, only with the module on
*/
export const TIMELINE_LANE_KEYS = [
"life",
"illness",
"allergies",
"medications",
"vaccinations",
"visits",
"labs",
"documents",
"cycle",
] as const;
export type TimelineLaneKey = (typeof TIMELINE_LANE_KEYS)[number];
/** What one lane item is. */
export const TIMELINE_ITEM_KINDS = [
"lifeEvent",
"travel",
"episode",
"chronic",
"allergy",
"medication",
"course",
"doseChange",
"pause",
"vaccination",
"visit",
"procedure",
"labDay",
"document",
"cycle",
] as const;
export type TimelineItemKind = (typeof TIMELINE_ITEM_KINDS)[number];
/**
* The readiness inventory, one row per lane plus the value series. `values`
* is always listed; `mood`, `environment` and `cycle` only with their module
* on.
*/
export const TIMELINE_READINESS_KEYS = [
"values",
...TIMELINE_LANE_KEYS,
"mood",
"environment",
] as const;
export type TimelineReadinessKey = (typeof TIMELINE_READINESS_KEYS)[number];
/** How well one lane is filled. Counts, never a score. */
export const TIMELINE_READINESS_STATUSES = [
"carries",
"thin",
"empty",
] as const;
export type TimelineReadinessStatus =
(typeof TIMELINE_READINESS_STATUSES)[number];
/**
* The one-sentence verdict: `carries` from three carrying lanes including
* the values, otherwise `thin`.
*/
export const TIMELINE_READINESS_VERDICTS = ["carries", "thin"] as const;
export type TimelineReadinessVerdict =
(typeof TIMELINE_READINESS_VERDICTS)[number];
/* ─── Life events ─────────────────────────────────────────────────────────── */
/** Mirrors the Prisma enum `LifeEventCategory`. */
export const LIFE_EVENT_CATEGORIES = [
"FAMILY",
"HOME",
"WORK",
"LOSS",
"OTHER",
] as const;
export type LifeEventCategory = (typeof LIFE_EVENT_CATEGORIES)[number];
/** Mirrors the Prisma enum `LifeEventPrecision`. Applies to both ends. */
export const LIFE_EVENT_PRECISIONS = ["DAY", "MONTH", "YEAR"] as const;
export type LifeEventPrecision = (typeof LIFE_EVENT_PRECISIONS)[number];
/** Longest title, in characters. */
export const LIFE_EVENT_TITLE_MAX = 120;
/** Longest note, in characters. */
export const LIFE_EVENT_NOTE_MAX = 2000;
/* ─── Coach and MCP ───────────────────────────────────────────────────────── */
/**
* The read tool the Coach and the MCP endpoint get for one day. Reserved
* here so both registries spell it the same; the loader is the day route's.
* Its answer never carries a {@link MODEL_EXCLUDED_DAY_SECTIONS} section.
*/
export const DAY_TOOL_NAME = "get_day";
/** The tool's single argument. */
export interface DayToolInput {
date: DateKey;
}
/* ─── Wire types, inferred from the schemas ───────────────────────────────── */
/** `GET /api/day/{date}` → `data`. */
export type DayResponse = z.infer<typeof dayResponseSchema>;
export type DayRunningItem = DayResponse["running"][number];
export type DayValue = DayResponse["values"][number];
export type DayEvent = DayResponse["events"][number];
export type DayNotable = DayResponse["notable"][number];
/** `GET /api/day/index` → `data`. */
export type DayIndexResponse = z.infer<typeof dayIndexResponseSchema>;
/** `GET /api/timeline` query and `data`. */
export type TimelineQuery = z.infer<typeof timelineQuerySchema>;
export type TimelineResponse = z.infer<typeof timelineResponseSchema>;
export type TimelineLane = TimelineResponse["lanes"][number];
export type TimelineItem = TimelineLane["items"][number];
export type TimelineSeries = TimelineResponse["series"][number];
/** `GET /api/timeline/readiness` → `data`. */
export type TimelineReadinessResponse = z.infer<
typeof timelineReadinessResponseSchema
>;
export type TimelineReadinessLane = TimelineReadinessResponse["lanes"][number];
/** One life event as the routes return it. */
export type LifeEventDTO = z.infer<typeof lifeEventSchema>;
export type LifeEventListResponse = z.infer<typeof lifeEventListResponseSchema>;
export type LifeEventCreateInput = z.infer<typeof lifeEventCreateSchema>;
export type LifeEventUpdateInput = z.infer<typeof lifeEventUpdateSchema>;
+407
View File
@@ -0,0 +1,407 @@
/**
* Zod schemas for the day view, the timeline and life events (v1.42, #613).
*
* The routes parse their input with these, the OpenAPI table publishes them,
* and `./contract.ts` infers its wire types from them, so the request a route
* accepts, the shape it promises and the type a client reads are one thing.
* Server and OpenAPI side only: client code imports the types from
* `./contract.ts`, never this file, so no validator lands in the browser.
*/
import { z } from "zod/v4";
import { dateOnlyAtNoonUtc, isCalendarDateKey } from "@/lib/tz/date-only";
import {
DAY_EVENT_KINDS,
DAY_INDEX_MAX_SPAN_DAYS,
DAY_NOTABLE_KINDS,
DAY_RUNNING_KINDS,
DAY_SECTION_KEYS,
DAY_SECTION_UNAVAILABLE_REASONS,
LIFE_EVENT_CATEGORIES,
LIFE_EVENT_NOTE_MAX,
LIFE_EVENT_PRECISIONS,
LIFE_EVENT_TITLE_MAX,
TIMELINE_ITEM_KINDS,
TIMELINE_LANE_KEYS,
TIMELINE_READINESS_KEYS,
TIMELINE_READINESS_STATUSES,
TIMELINE_READINESS_VERDICTS,
TIMELINE_ZOOMS,
} from "./contract";
/* ─── Shared pieces ───────────────────────────────────────────────────────── */
export const dateKeySchema = z
.string()
.refine(isCalendarDateKey, { message: "Expected a YYYY-MM-DD calendar date" })
.describe("A local calendar date, `YYYY-MM-DD`.");
const instant = z.iso.datetime({ offset: true });
const messageParams = z
.record(z.string(), z.union([z.string(), z.number()]))
.describe(
"Parameters for the message the client words from its own bundle. Dates are `YYYY-MM-DD`, values are numbers in the unit the entry names.",
);
const daySectionKey = z.enum(DAY_SECTION_KEYS);
/* ─── GET /api/day/{date} ─────────────────────────────────────────────────── */
export const dayPathSchema = z.object({ date: dateKeySchema });
const dayRunningItem = z
.object({
kind: z.enum(DAY_RUNNING_KINDS),
section: daySectionKey,
id: z.string(),
title: z
.string()
.describe("Decrypted server-side for the caller's own session."),
sub: z
.string()
.nullable()
.describe("A second line, already formatted (a dose, a destination)."),
since: dateKeySchema,
until: dateKeySchema.nullable().describe("Null while it is still open."),
dayIndex: z
.number()
.int()
.positive()
.nullable()
.describe("Day n of the period, counting `since` as day 1."),
dayCount: z
.number()
.int()
.positive()
.nullable()
.describe("Length of a closed period in days; null while open."),
href: z
.string()
.nullable()
.describe("In-app path to the record this came from."),
})
.meta({ id: "DayRunningItem" });
const dayValue = z
.object({
type: z.string().describe("A `MeasurementType`."),
value: z.number(),
unit: z.string(),
at: instant,
source: z
.string()
.describe("The `MeasurementSource` of the canonical row."),
band: z
.object({
lo: z.number(),
hi: z.number(),
n: z.number().int().nonnegative(),
})
.nullable()
.describe(
"The person's own usual range over the 30 days before this day, from their daily values. Null with too little history.",
),
})
.meta({ id: "DayValue" });
const dayEvent = z
.object({
at: instant
.nullable()
.describe("The instant, or null for something that has a date only."),
kind: z.enum(DAY_EVENT_KINDS),
section: daySectionKey,
id: z.string(),
title: z.string(),
meta: z.string().nullable(),
note: z
.string()
.nullable()
.describe("The person's own note, decrypted for the caller's session."),
docs: z
.array(z.object({ id: z.string(), name: z.string() }))
.describe("Documents filed against the entry."),
href: z.string().nullable(),
})
.meta({ id: "DayEvent" });
const dayNotable = z
.object({
kind: z.enum(DAY_NOTABLE_KINDS),
type: z.string().nullable().describe("The `MeasurementType` it is about."),
params: messageParams,
})
.meta({ id: "DayNotable" });
export const dayResponseSchema = z
.object({
date: dateKeySchema,
tz: z.string().describe("The IANA zone the day was cut in."),
counts: z.object({
values: z.number().int().nonnegative(),
entries: z.number().int().nonnegative(),
}),
running: z.array(dayRunningItem),
values: z.array(dayValue),
events: z.array(dayEvent),
notable: z.array(dayNotable),
sections: z
.partialRecord(
daySectionKey,
z.object({
available: z.literal(false),
reason: z.enum(DAY_SECTION_UNAVAILABLE_REASONS),
}),
)
.describe(
"Sections the caller cannot see. A section of a switched-off module is left out entirely rather than listed.",
),
})
.meta({ id: "Day" });
/* ─── GET /api/day/index ──────────────────────────────────────────────────── */
export const dayIndexQuerySchema = z
.object({ from: dateKeySchema, to: dateKeySchema })
.refine((q) => q.from <= q.to, {
message: "`from` must not be after `to`",
path: ["to"],
})
.refine(
// Both ends as the same instant of their calendar day, so the difference
// counts calendar days whatever the process zone.
(q) =>
(dateOnlyAtNoonUtc(q.to).getTime() -
dateOnlyAtNoonUtc(q.from).getTime()) /
86_400_000 <
DAY_INDEX_MAX_SPAN_DAYS,
{
message: `At most ${DAY_INDEX_MAX_SPAN_DAYS} days`,
path: ["to"],
},
);
export const dayIndexResponseSchema = z
.object({
from: dateKeySchema,
to: dateKeySchema,
days: z
.record(z.string(), z.array(daySectionKey))
.describe(
"Each day in the window that holds anything, keyed `YYYY-MM-DD`, with the sections it holds. Days without entries are absent.",
),
notable: z
.array(z.string())
.describe("Days in the window that carry a notable observation."),
})
.meta({ id: "DayIndex" });
/* ─── GET /api/timeline ───────────────────────────────────────────────────── */
export const timelineQuerySchema = z.object({
zoom: z.enum(TIMELINE_ZOOMS).default("all"),
from: dateKeySchema.optional(),
to: dateKeySchema.optional(),
values: z
.string()
.optional()
.describe(
"Comma-separated value series to include, as `MeasurementType` names or `MOOD`.",
),
});
const timelineItem = z
.object({
id: z.string(),
kind: z.enum(TIMELINE_ITEM_KINDS),
start: dateKeySchema,
end: dateKeySchema.nullable().describe("Null for a point or an open span."),
open: z.boolean().describe("A span that has not ended."),
precision: z.enum(LIFE_EVENT_PRECISIONS),
startKnown: z
.boolean()
.describe(
"False when the start is a stand-in (a medication without a start date starts at its first intake).",
),
label: z.string(),
sub: z.string().nullable(),
href: z.string().nullable(),
})
.meta({ id: "TimelineItem" });
const timelineStanding = z
.object({
lane: z.enum(TIMELINE_LANE_KEYS),
id: z.string(),
label: z.string(),
since: dateKeySchema.nullable(),
href: z.string().nullable(),
})
.meta({ id: "TimelineStandingItem" });
const timelineSeries = z
.object({
key: z.string().describe("A `MeasurementType` or `MOOD`."),
unit: z.string().nullable(),
granularity: z.enum(["month", "week", "day"]),
points: z.array(z.object({ t: dateKeySchema, mean: z.number() })),
})
.meta({ id: "TimelineSeries" });
export const timelineResponseSchema = z
.object({
zoom: z.enum(TIMELINE_ZOOMS),
range: z.object({
from: dateKeySchema,
to: dateKeySchema,
dataFrom: dateKeySchema
.nullable()
.describe("The earliest date anything in the record carries."),
}),
lanes: z.array(
z.object({
key: z.enum(TIMELINE_LANE_KEYS),
items: z.array(timelineItem),
}),
),
standing: z
.array(timelineStanding)
.describe("Things without a start: shown once as standing."),
series: z.array(timelineSeries),
notable: z.array(
z.object({ date: dateKeySchema, kind: z.enum(DAY_NOTABLE_KINDS) }),
),
})
.meta({ id: "Timeline" });
/* ─── GET /api/timeline/readiness ─────────────────────────────────────────── */
export const timelineReadinessResponseSchema = z
.object({
verdict: z.enum(TIMELINE_READINESS_VERDICTS),
since: dateKeySchema
.nullable()
.describe("The earliest date the carrying lanes reach back to."),
lanes: z.array(
z.object({
key: z.enum(TIMELINE_READINESS_KEYS),
status: z.enum(TIMELINE_READINESS_STATUSES),
count: z.number().int().nonnegative(),
detail: z
.object({ key: z.string(), params: messageParams })
.nullable()
.describe("Worded by the client from `timeline.readiness.detail`."),
gaps: z.array(
z.object({
key: z.string(),
count: z.number().int().nonnegative(),
href: z.string(),
}),
),
}),
),
})
.meta({ id: "TimelineReadiness" });
/* ─── /api/life-events ────────────────────────────────────────────────────── */
/**
* At MONTH precision a date is the first of its month, at YEAR the first of
* its year. Storing the stand-in day keeps the column sortable and the rule
* keeps two spellings of "September 2023" from existing.
*/
function alignedToPrecision(
date: string,
precision: (typeof LIFE_EVENT_PRECISIONS)[number],
): boolean {
if (precision === "MONTH") return date.endsWith("-01");
if (precision === "YEAR") return date.endsWith("-01-01");
return true;
}
const lifeEventTitle = z.string().trim().min(1).max(LIFE_EVENT_TITLE_MAX);
const lifeEventNote = z.string().trim().max(LIFE_EVENT_NOTE_MAX);
export const lifeEventSchema = z
.object({
id: z.string(),
category: z.enum(LIFE_EVENT_CATEGORIES),
startDate: dateKeySchema,
endDate: dateKeySchema.nullable(),
precision: z.enum(LIFE_EVENT_PRECISIONS),
title: z
.string()
.nullable()
.describe("Decrypted; null only when the stored title cannot be read."),
note: z.string().nullable(),
createdAt: instant,
updatedAt: instant,
})
.meta({
id: "LifeEvent",
description:
"A dated anchor in the person's life. Shared under the `profile` domain; never sent to a model.",
});
export const lifeEventListResponseSchema = z
.object({ events: z.array(lifeEventSchema) })
.meta({ id: "LifeEventList" });
export const lifeEventCreateSchema = z
.object({
category: z.enum(LIFE_EVENT_CATEGORIES),
startDate: dateKeySchema,
endDate: dateKeySchema.nullable().optional(),
precision: z.enum(LIFE_EVENT_PRECISIONS),
title: lifeEventTitle,
note: lifeEventNote.nullable().optional(),
})
.strict()
.superRefine((value, ctx) => {
if (!alignedToPrecision(value.startDate, value.precision)) {
ctx.addIssue({
code: "custom",
path: ["startDate"],
message: "The date must be the first day of its month or year",
});
}
if (value.endDate != null) {
if (!alignedToPrecision(value.endDate, value.precision)) {
ctx.addIssue({
code: "custom",
path: ["endDate"],
message: "The date must be the first day of its month or year",
});
}
if (value.endDate < value.startDate) {
ctx.addIssue({
code: "custom",
path: ["endDate"],
message: "`endDate` must not be before `startDate`",
});
}
}
})
.meta({ id: "CreateLifeEventRequest" });
/**
* A partial edit. The cross-field rules are checked against the stored row,
* because a precision change alone can misalign a date the request does not
* carry; the route merges and re-checks with `lifeEventCreateSchema`.
*/
export const lifeEventUpdateSchema = z
.object({
category: z.enum(LIFE_EVENT_CATEGORIES),
startDate: dateKeySchema,
endDate: dateKeySchema.nullable(),
precision: z.enum(LIFE_EVENT_PRECISIONS),
title: lifeEventTitle,
note: lifeEventNote.nullable(),
})
.partial()
.strict()
.meta({ id: "UpdateLifeEventRequest" });
export const lifeEventIdPathSchema = z.object({ id: z.string() });
@@ -444,6 +444,7 @@ function makePrisma() {
// builder must still answer for.
ecgRecording: { findMany: vi.fn().mockResolvedValue([]) },
symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) },
lifeEvent: { findMany: vi.fn().mockResolvedValue([]) },
// Left unmocked on purpose: `buildProfileBackupSection` runs for real
// against these, so the assertions below exercise the builder rather than
// a stand-in that would agree with whatever the payload happened to do.
@@ -315,6 +315,7 @@ function sourceClient() {
extractedFact: { findMany: vi.fn().mockResolvedValue([]) },
ecgRecording: { findMany: vi.fn().mockResolvedValue([]) },
symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) },
lifeEvent: { findMany: vi.fn().mockResolvedValue([]) },
customMetric: {
findMany: vi.fn().mockResolvedValue([
{
+7
View File
@@ -111,6 +111,9 @@ export const BACKED_UP_MODELS = [
// else holds either; a restore without them loses the history outright.
"SymptomDefinition",
"SymptomEvent",
// v1.42 — the person's life events, the timeline's anchors. Written by the
// person and held nowhere else.
"LifeEvent",
"EcgRecording",
"UserHealthProfile",
"HealthProfileFactRevision",
@@ -274,6 +277,8 @@ export const BACKUP_WRITER_FILES: readonly string[] = [
// ride inside their definition (`include: { events }` out, `events: {
// create }` back), the way a medication's side effects do.
"src/lib/export/symptoms-backup.ts",
// The person's life events, both ends beside each other (v1.42).
"src/lib/export/life-events-backup.ts",
"src/lib/cycle/backup.ts",
// The medication category is read through the one helper that normalises
// it; the payload writer calls the helper rather than the delegate. The
@@ -297,6 +302,7 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [
"src/lib/export/onboarding-backup.ts",
"src/lib/export/ecg-backup.ts",
"src/lib/export/symptoms-backup.ts",
"src/lib/export/life-events-backup.ts",
"src/lib/cycle/backup.ts",
// The restore writes the medication category through the same helper.
"src/lib/medication-category.ts",
@@ -344,6 +350,7 @@ export const TWO_ENDED_MODELS = [
"IllnessSymptomLink",
"SymptomDefinition",
"SymptomEvent",
"LifeEvent",
"UserHealthProfile",
"HealthProfileFactRevision",
"CycleProfile",
+17 -1
View File
@@ -115,6 +115,12 @@ import {
type SymptomsBackupCounts,
type SymptomsBackupSection,
} from "@/lib/export/symptoms-backup";
import {
buildLifeEventsBackupSection,
countLifeEventsBackupSection,
type LifeEventsBackupCounts,
type LifeEventsBackupSection,
} from "@/lib/export/life-events-backup";
import {
buildIntradayProfileBackupSection,
countIntradayProfileBackupSection,
@@ -153,7 +159,8 @@ export interface FullBackupCounts
EnvironmentBackupCounts,
OnboardingBackupCounts,
EcgBackupCounts,
SymptomsBackupCounts {
SymptomsBackupCounts,
LifeEventsBackupCounts {
measurements: number;
medications: number;
intakeEvents: number;
@@ -724,6 +731,7 @@ export async function buildFullBackupPayload(
onboardingRecord,
ecg,
symptoms,
lifeEvents,
nutrientDays,
accountSettings,
] = await Promise.all([
@@ -908,6 +916,11 @@ export async function buildFullBackupPayload(
buildSymptomsBackupSection(prisma, userId, {
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
}),
// The person's life events (v1.42). Both ends live in
// `src/lib/export/life-events-backup.ts`.
buildLifeEventsBackupSection(prisma, userId, {
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
}),
// Nutrient day totals were absent from every export path, which
// contradicted the schema's own reason for denormalising the unit column
// ("rows stay self-describing in exports even if the catalog ever drifts").
@@ -962,6 +975,7 @@ export async function buildFullBackupPayload(
const environmentSection: EnvironmentBackupSection = environment;
const ecgSection: EcgBackupSection = ecg;
const symptomsSection: SymptomsBackupSection = symptoms;
const lifeEventsSection: LifeEventsBackupSection = lifeEvents;
const accountSettingsSection: AccountSettingsBackupSection = accountSettings;
// The three unbounded tables, either read into arrays or left as markers
@@ -1363,6 +1377,7 @@ export async function buildFullBackupPayload(
...environmentSection,
...ecgSection,
...symptomsSection,
...lifeEventsSection,
nutrientDays: nutrientDays.map((n) => ({
day: n.day,
nutrient: n.nutrient,
@@ -1431,6 +1446,7 @@ export async function buildFullBackupPayload(
...countEnvironmentBackupSection(environment),
...countEcgBackupSection(ecg),
...countSymptomsBackupSection(symptoms),
...countLifeEventsBackupSection(lifeEvents),
},
};
}
+205
View File
@@ -0,0 +1,205 @@
/**
* Life events (v1.42, #613), with both backup ends in one file.
*
* Same arrangement as `symptoms-backup.ts`: a reader asking "is this carried
* at both ends?" answers it here, and a reader who greps only the restore
* route gets a false negative because the route delegates.
*
* ## Free text follows the note contract
*
* A disaster-recovery payload carries the title and note ciphertext verbatim
* as base64, which the same instance's key reads back unchanged, and keeps
* the soft-deleted rows with their tombstones so an undo window survives the
* round trip. A portable export decrypts both, because a portable file exists
* to be readable by the person who owns it and to restore under another
* instance's key, and leaves the deleted rows out. A title this instance
* cannot decrypt travels as the visible unreadable marker rather than as
* nothing, the rule `records-backup.ts` states for notes.
*
* Nothing references a life event and a life event references nothing but
* its owner, so the section restores in any order against the others.
*/
import { Buffer } from "node:buffer";
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import type {
LifeEventCategory,
LifeEventPrecision,
} from "@/generated/prisma/client";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { getEvent } from "@/lib/logging/context";
import { UNREADABLE_EXPORT_MARKER } from "./unreadable-marker";
export interface LifeEventsBackupOptions {
purpose?: "portable-export" | "disaster-recovery";
}
/** One life event as the backup carries it. */
export interface LifeEventBackupEntry {
/** Present in a disaster-recovery payload. */
id?: string;
category: LifeEventCategory;
startDate: string;
endDate: string | null;
precision: LifeEventPrecision;
/** Plaintext; present on a portable payload. */
title?: string;
/** Ciphertext as base64; present on a disaster-recovery payload. */
titleEncrypted?: string;
/** Plaintext; present on a portable payload. */
note?: string | null;
/** Ciphertext as base64; present on a disaster-recovery payload. */
noteEncrypted?: string | null;
createdAt: string;
updatedAt: string;
/** Present on a disaster-recovery payload. */
deletedAt?: string | null;
}
export interface LifeEventsBackupSection {
lifeEvents: LifeEventBackupEntry[];
}
export interface LifeEventsBackupCounts {
lifeEvents: number;
}
function base64(buf: Uint8Array): string {
return Buffer.from(buf).toString("base64");
}
function readTextForExport(buf: Uint8Array, field: string): string {
try {
return decryptFromBytes(buf);
} catch (err) {
getEvent()?.addWarning(
`life event ${field} decrypt failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return UNREADABLE_EXPORT_MARKER;
}
}
/** Build the life-event slice of a user's full backup. */
export async function buildLifeEventsBackupSection(
prisma: Pick<PrismaClient, "lifeEvent">,
userId: string,
options: LifeEventsBackupOptions = {},
): Promise<LifeEventsBackupSection> {
const disasterRecovery = options.purpose === "disaster-recovery";
const rows = await prisma.lifeEvent.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: [{ startDate: "asc" }, { createdAt: "asc" }, { id: "asc" }],
});
return {
lifeEvents: rows.map((row) => ({
...(disasterRecovery
? {
id: row.id,
titleEncrypted: base64(row.titleEncrypted),
noteEncrypted: row.noteEncrypted ? base64(row.noteEncrypted) : null,
}
: {
title: readTextForExport(row.titleEncrypted, "title"),
note: row.noteEncrypted
? readTextForExport(row.noteEncrypted, "note")
: null,
}),
category: row.category,
startDate: row.startDate,
endDate: row.endDate,
precision: row.precision,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
...(disasterRecovery
? { deletedAt: row.deletedAt?.toISOString() ?? null }
: {}),
})),
};
}
export function countLifeEventsBackupSection(
section: LifeEventsBackupSection,
): LifeEventsBackupCounts {
return { lifeEvents: section.lifeEvents.length };
}
/** Counts the life-event restore wiped, for the audit trail. */
export interface LifeEventsRestoreCleared {
lifeEvents: number;
}
/**
* What the parser hands over, which is looser than what the builder writes:
* a file from before v1.42 carries no key at all, and optional columns arrive
* as `undefined` rather than `null`.
*/
export interface LifeEventsRestoreInput {
lifeEvents: Array<{
id?: string | undefined;
category: LifeEventCategory;
startDate: string;
endDate?: string | null | undefined;
precision: LifeEventPrecision;
title?: string | undefined;
titleEncrypted?: string | undefined;
note?: string | null | undefined;
noteEncrypted?: string | null | undefined;
createdAt: string;
updatedAt: string;
deletedAt?: string | null | undefined;
}>;
}
function decodeBytes(encoded: string): Uint8Array<ArrayBuffer> {
const decoded = Buffer.from(encoded, "base64");
const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength));
bytes.set(decoded);
return bytes;
}
/**
* Re-create the account's life events. Delete-then-recreate inside the
* caller's transaction, like every other section.
*/
export async function restoreLifeEventsData(
tx: Prisma.TransactionClient,
ownerId: string,
payload: LifeEventsRestoreInput,
): Promise<LifeEventsRestoreCleared> {
const cleared = await tx.lifeEvent.deleteMany({ where: { userId: ownerId } });
if (payload.lifeEvents.length > 0) {
await tx.lifeEvent.createMany({
data: payload.lifeEvents.map((event) => ({
...(event.id ? { id: event.id } : {}),
userId: ownerId,
category: event.category,
startDate: event.startDate,
endDate: event.endDate ?? null,
precision: event.precision,
titleEncrypted:
event.titleEncrypted !== undefined
? decodeBytes(event.titleEncrypted)
: encryptToBytes(event.title ?? ""),
noteEncrypted:
event.noteEncrypted !== undefined
? event.noteEncrypted === null
? null
: decodeBytes(event.noteEncrypted)
: event.note
? encryptToBytes(event.note)
: null,
createdAt: new Date(event.createdAt),
updatedAt: new Date(event.updatedAt),
deletedAt: event.deletedAt ? new Date(event.deletedAt) : null,
})),
});
}
return { lifeEvents: cleared.count };
}
+13
View File
@@ -114,6 +114,7 @@ import { restoreAwardsData } from "@/lib/export/awards-backup";
import { restoreEnvironmentData } from "@/lib/export/environment-backup";
import { restoreEcgData } from "@/lib/export/ecg-backup";
import { restoreSymptomsData } from "@/lib/export/symptoms-backup";
import { restoreLifeEventsData } from "@/lib/export/life-events-backup";
import { restoredMedicationCreatedAt } from "@/lib/export/medication-created-at";
import {
encryptCategoryLabel,
@@ -195,6 +196,7 @@ export interface RestoreResponse {
environmentTravelLocations: number;
ecgRecordings: number;
symptomDefinitions: number;
lifeEvents: number;
};
}
@@ -2276,6 +2278,16 @@ export async function restoreBackup(
skips,
);
// The person's life events (v1.42). They reference nothing but the
// account, so they have no ordering constraint and sit beside the
// other person-made rows. Both ends live in
// `src/lib/export/life-events-backup.ts`.
const lifeEventsCleared = await restoreLifeEventsData(
tx,
ownerId,
payload,
);
reportSection("allergies");
for (const allergy of payload.allergies) {
await tx.allergy.create({
@@ -2663,6 +2675,7 @@ export async function restoreBackup(
environmentCleared.environmentTravelLocations,
ecgRecordings: ecgCleared.ecgRecordings,
symptomDefinitions: symptomsCleared.symptomDefinitions,
lifeEvents: lifeEventsCleared.lifeEvents,
};
assertNoNewForeignReferences(
foreignBefore,
+5 -1
View File
@@ -374,7 +374,11 @@ describe("registry — opt-in marker", () => {
// default-on disabled-allowlist now; their egress channels (the weather
// fetch, the document AI-read, the AI Coach exposure) are separately gated
// and stay off until explicitly configured.
const OPT_IN_KEYS = new Set(["mcp", "nutrients"]);
//
// v1.42 — `timeline` joins them for a different reason: it opens no channel,
// but it is a new way of reading the whole record, and the readiness
// inventory shown when it is switched on is meant to be the first contact.
const OPT_IN_KEYS = new Set(["mcp", "nutrients", "timeline"]);
it("marks only the egress-surface modules opt-in (every other is default-on)", () => {
for (const key of MODULE_KEYS) {
@@ -67,6 +67,7 @@ import {
} from "@/lib/insights/sub-page-metric";
import { TREND_CHART_CONFIG } from "@/lib/insights/trend-chart-select";
import { SYMPTOM_CHANNEL_PREFIX } from "@/lib/symptoms/shared";
import { DAY_SECTION_KEYS, TIMELINE_LANE_KEYS } from "@/lib/day/contract";
import {
SURFACE_KINDS,
SURFACE_MODULE,
@@ -156,6 +157,20 @@ describe("every surface id names a surface that exists", () => {
}
});
it("day-section: a section the day view renders", () => {
const keys = new Set<string>(DAY_SECTION_KEYS);
for (const id of localIds("day-section")) {
expect(keys.has(id), id).toBe(true);
}
});
it("timeline-lane: a lane the timeline renders", () => {
const keys = new Set<string>(TIMELINE_LANE_KEYS);
for (const id of localIds("timeline-lane")) {
expect(keys.has(id), id).toBe(true);
}
});
it("correlation: a discovery channel, a weather field or the lab pass", () => {
const channels = new Set<string>([
...DISCOVERY_BEHAVIOURS,
@@ -269,6 +284,10 @@ describe("every surface that shows a module's data is owned by it", () => {
["correlation:MEDICATION_COMPLIANCE", "medications"],
["correlation:SYMPTOM_SEVERITY", "illness"],
["correlation:LAB_DRAWS", "labs"],
["nav:/timeline", "timeline"],
["day-section:lifeEvents", "timeline"],
["day-section:symptoms", "illness"],
["timeline-lane:documents", "inboundDocuments"],
])("%s is owned by %s", (id, owner) => {
expect(surfaceModule(id)).toBe(owner);
});
+10
View File
@@ -44,6 +44,7 @@ type DomainDataClient = Pick<
| "mentalHealthAssessment"
| "nutrientIntakeDay"
| "vaccinationRecord"
| "lifeEvent"
>;
type DomainProbe = (
@@ -201,6 +202,15 @@ const MODULE_DOMAIN_PROBES: Record<OwnedModuleKey, DomainProbe> = {
select: { id: true },
}),
),
// The timeline's own content is the life events; everything else on it
// belongs to other modules and says nothing about whether this one is used.
timeline: (client, recordId) =>
anyRow(
client.lifeEvent.findFirst({
where: { userId: recordId, deletedAt: null },
select: { id: true },
}),
),
};
/**
+21
View File
@@ -173,6 +173,19 @@ export const MODULE_KEYS = [
// surfaces the person made content for; it does not silently retract a
// commitment the app made to them.
"vaccinations",
// v1.42 (#613) — the life timeline (`/timeline`): years at a glance, the
// record's conditions, medications, visits and documents in lanes beside the
// value series, plus the person's own life events. OPT-IN (off by default):
// not because it egresses (it does not), but because it is a new way of
// looking at the whole record, and the readiness inventory that greets the
// switch is the right first contact with it. The day view is NOT this
// module: it is core and opens for every account.
//
// Life events belong to this module. They are only created from the
// timeline and the day's capture sheet while it is on, and they disappear
// from the day with it; the `/api/life-events` row store stays reachable so
// a restore keeps working and re-enabling finds the events intact.
"timeline",
] as const;
export type ModuleKey = (typeof MODULE_KEYS)[number];
@@ -369,6 +382,14 @@ export const MODULE_REGISTRY: Readonly<Record<ModuleKey, ModuleDefinition>> =
// No `optIn`: default-on. Nothing leaves the instance and the surfaces
// are inert until the person logs a dose.
},
timeline: {
key: "timeline",
labelKey: "modules.timeline.label",
descriptionKey: "modules.timeline.description",
category: "tracking",
// Off by default; see the key's comment in `MODULE_KEYS`.
optIn: true,
},
});
const MODULE_KEY_SET: ReadonlySet<string> = new Set(MODULE_KEYS);
+40
View File
@@ -48,6 +48,10 @@ import type { ModuleKey } from "@/lib/modules/registry";
* - `settings-layout`: a Settings, Layout group, by its id.
* - `correlation`: a correlation-discovery channel, by its channel key.
* - `score-ring`: a dashboard hero score ring, by its `ScoreRingId`.
* - `day-section`: a section of the day view, by its `DaySectionKey`. The
* day loader blanks a section whose owner is off, and the views drop it.
* - `timeline-lane`: a lane of the timeline, by its `TimelineLaneKey`; the
* readiness inventory leaves it out the same way.
*/
export const SURFACE_KINDS = [
"nav",
@@ -62,6 +66,8 @@ export const SURFACE_KINDS = [
"settings-layout",
"correlation",
"score-ring",
"day-section",
"timeline-lane",
] as const;
export type SurfaceKind = (typeof SURFACE_KINDS)[number];
@@ -79,6 +85,7 @@ const STATIC_SURFACE_MODULE = {
"nav:/documents": "inboundDocuments",
"nav:/coach": "coach",
"nav:/achievements": "achievements",
"nav:/timeline": "timeline",
// ── Insights sub-pages ──
"insights-page:mood": "mood",
@@ -205,6 +212,39 @@ const STATIC_SURFACE_MODULE = {
"correlation:SYMPTOM_SEVERITY": "illness",
// The labs ↔ outcome pass over lab draws.
"correlation:LAB_DRAWS": "labs",
// ── Day view sections (v1.42) ──
// The readings carry no owner here: each value follows its own type's
// module (`moduleForMeasurementType`), so a section-level owner would hide
// the core vitals with it. Allergies, visits, check-ups and the anamnesis
// facts are core record surfaces and stay unowned.
"day-section:sleep": "sleep",
"day-section:mood": "mood",
"day-section:assessments": "mentalHealth",
"day-section:medications": "medications",
"day-section:illness": "illness",
// Person-defined symptoms ride the illness module, as everywhere else.
"day-section:symptoms": "illness",
"day-section:labs": "labs",
"day-section:vaccinations": "vaccinations",
"day-section:documents": "inboundDocuments",
"day-section:workouts": "workouts",
"day-section:cycle": "cycle",
"day-section:environment": "environment",
// Life events are the timeline module's own content: off, they leave the
// day as well.
"day-section:lifeEvents": "timeline",
// ── Timeline lanes (v1.42) ──
// The `life` lane mixes life events (this module) with travel periods (the
// environment module) and is filtered item by item; allergies and visits are
// core.
"timeline-lane:illness": "illness",
"timeline-lane:medications": "medications",
"timeline-lane:vaccinations": "vaccinations",
"timeline-lane:labs": "labs",
"timeline-lane:documents": "inboundDocuments",
"timeline-lane:cycle": "cycle",
} as const satisfies Record<string, ModuleKey>;
/** A surface id the map declares literally. */
+6 -1
View File
@@ -46,7 +46,12 @@ export const SHARED_RECORD_DOMAIN_ROUTE_FAMILIES = {
* Routes whose reads span multiple domains. A selected-domain grant never
* opens these, even when it happens to name every currently known domain.
*/
const WHOLE_RECORD_ROUTE_FAMILIES = ["/", "/achievements"] as const;
const WHOLE_RECORD_ROUTE_FAMILIES = [
"/",
"/achievements",
// v1.42 — the timeline lays every section side by side.
"/timeline",
] as const;
interface RouteFamily {
href: string;
+229
View File
@@ -0,0 +1,229 @@
/**
* OpenAPI route table for the day view, the timeline and life events
* (`/api/day`, `/api/timeline`, `/api/life-events`, v1.42, #613).
*
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request
* and response schemas are the runtime ones from `@/lib/day/wire-schemas`, so
* the published contract is the parser's own. Published ahead of the
* implementation: every operation answers 501 until its route lands.
*/
import type { ZodOpenApiObject } from "zod-openapi";
import {
dayIndexQuerySchema,
dayIndexResponseSchema,
dayPathSchema,
dayResponseSchema,
lifeEventCreateSchema,
lifeEventIdPathSchema,
lifeEventListResponseSchema,
lifeEventSchema,
lifeEventUpdateSchema,
timelineQuerySchema,
timelineReadinessResponseSchema,
timelineResponseSchema,
} from "@/lib/day/wire-schemas";
import {
dataEnvelope,
errorEnvelope,
notImplementedResponse,
recordRefusal,
recordWriteRateLimitResponse,
stdResponses,
} from "./shared";
const TIMELINE_OFF =
"`module.disabled` with `meta.module = timeline`: the record has the timeline switched off (it is opt-in), or the operator turned it off server-wide.";
const lifeEventNotFound = {
"404": {
description: "No such life event on this record.",
content: { "application/json": { schema: errorEnvelope } },
},
};
export const dayPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/day/index": {
get: {
tags: ["Records"],
summary: "Days with entries",
description:
"Which local days in a window hold anything, by section, and which carry a notable observation. At most 366 days per call. Days are cut in the record's own time zone.",
requestParams: { query: dayIndexQuerySchema },
responses: {
"200": {
description: "The days in the window that hold entries.",
content: {
"application/json": {
schema: dataEnvelope(dayIndexResponseSchema, "DayIndexEnvelope"),
},
},
},
...stdResponses,
...recordRefusal(),
...notImplementedResponse,
},
},
},
"/api/day/{date}": {
get: {
tags: ["Records"],
summary: "One day across the record",
description:
"Everything on one local calendar day: what ran through it (medications, an illness, a cycle phase, a trip), the readings in its window with the person's usual range, what happened on it, and deterministic notable observations as keys with parameters. Sections of a switched-off module are left out; sections the caller's grant does not cover are named in `sections`. Read live from the record, never from the UTC rollups. A date that is not a real calendar date answers 422.",
requestParams: { path: dayPathSchema },
responses: {
"200": {
description: "The day.",
content: {
"application/json": {
schema: dataEnvelope(dayResponseSchema, "DayEnvelope"),
},
},
},
...stdResponses,
...recordRefusal(),
...notImplementedResponse,
},
},
},
"/api/timeline": {
get: {
tags: ["Records"],
summary: "The record over the years",
description:
"Lanes of conditions, allergies, medications, vaccinations, visits, lab days, documents and life events, the standing items without a start, and monthly (or finer, by zoom) value series. Empty lanes are not sent; lanes of a switched-off module never are. Requires the opt-in `timeline` module.",
requestParams: { query: timelineQuerySchema },
responses: {
"200": {
description: "The timeline for the requested zoom and window.",
content: {
"application/json": {
schema: dataEnvelope(timelineResponseSchema, "TimelineEnvelope"),
},
},
},
...stdResponses,
...recordRefusal(TIMELINE_OFF),
...notImplementedResponse,
},
},
},
"/api/timeline/readiness": {
get: {
tags: ["Records"],
summary: "What the timeline can show",
description:
"One row per lane: carries, thin or empty, a count, a detail as a message key with parameters, and the gaps with one in-app link each. The verdict is one of two words, never a score. Requires the opt-in `timeline` module.",
responses: {
"200": {
description: "The readiness inventory.",
content: {
"application/json": {
schema: dataEnvelope(
timelineReadinessResponseSchema,
"TimelineReadinessEnvelope",
),
},
},
},
...stdResponses,
...recordRefusal(TIMELINE_OFF),
...notImplementedResponse,
},
},
},
"/api/life-events": {
get: {
tags: ["Records"],
summary: "List life events",
description:
"The record's life events, oldest first; soft-deleted ones are excluded. Title and note are decrypted for the caller. Part of the `profile` sharing domain. Life events are never sent to a model.",
responses: {
"200": {
description: "The life events.",
content: {
"application/json": {
schema: dataEnvelope(
lifeEventListResponseSchema,
"LifeEventListEnvelope",
),
},
},
},
...stdResponses,
...recordRefusal(),
...notImplementedResponse,
},
},
post: {
tags: ["Records"],
summary: "Add a life event",
description:
"A dated anchor in the person's life. `precision` says how much of the date is known and applies to both ends: at MONTH a date is the first of its month, at YEAR the first of its year. `endDate`, when given, is not before `startDate`. Title (1-120 characters) and note (up to 2000) are encrypted at rest.",
requestBody: {
required: true,
content: { "application/json": { schema: lifeEventCreateSchema } },
},
responses: {
"201": {
description: "Created.",
content: {
"application/json": {
schema: dataEnvelope(lifeEventSchema, "CreateLifeEventEnvelope"),
},
},
},
...stdResponses,
...recordWriteRateLimitResponse,
...notImplementedResponse,
},
},
},
"/api/life-events/{id}": {
patch: {
tags: ["Records"],
summary: "Edit a life event",
description:
"A partial edit; an omitted key leaves the column untouched. The merged event must still satisfy the create rules (dates aligned to the precision, end not before start).",
requestParams: { path: lifeEventIdPathSchema },
requestBody: {
required: true,
content: { "application/json": { schema: lifeEventUpdateSchema } },
},
responses: {
"200": {
description: "Updated.",
content: {
"application/json": {
schema: dataEnvelope(lifeEventSchema, "UpdateLifeEventEnvelope"),
},
},
},
...lifeEventNotFound,
...stdResponses,
...notImplementedResponse,
},
},
delete: {
tags: ["Records"],
summary: "Delete a life event",
description: "Soft-deletes one life event.",
requestParams: { path: lifeEventIdPathSchema },
responses: {
"200": {
description: "Deleted.",
content: {
"application/json": {
schema: dataEnvelope(lifeEventSchema, "DeleteLifeEventEnvelope"),
},
},
},
...lifeEventNotFound,
...stdResponses,
...notImplementedResponse,
},
},
},
};
+4
View File
@@ -48,6 +48,7 @@ import { customMetricPaths } from "./custom-metrics";
import { dailyPaths } from "./daily";
import { cyclePaths } from "./cycle";
import { dashboardWidgetPaths } from "./dashboard";
import { dayPaths } from "./day";
import { biomarkerPaths } from "./biomarkers";
import { inboundDocumentPaths } from "./documents";
import { documentSourcePaths } from "./document-sources";
@@ -181,6 +182,9 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
// v1.40 — person-defined symptoms and their occurrences, behind the illness
// module (appended, spread order is load-bearing).
...symptomPaths,
// v1.42 — the day view, the timeline and life events (appended, spread
// order is load-bearing).
...dayPaths,
// The environmental-context overview. Its own module because nothing else
// owns the surface (appended, spread order is load-bearing).
//
+18
View File
@@ -0,0 +1,18 @@
/**
* Query keys — the day view (v1.42, #613): one local day, and the index of
* days that hold anything over a window.
* Part of the centralized factory; aggregated in `./index.ts`.
*
* Everything sits under the `["day"]` root so a write to any record table can
* evict every open day at once: the record data bundles in `./index.ts` carry
* the root, and a day is cheap enough to re-read that finer keys would only
* add ways to miss one.
*/
export const dayKeys = {
/** Root prefix; the data-dependent bundles invalidate through it. */
dayRoot: () => ["day"] as const,
/** `GET /api/day/{date}`. */
day: (date: string) => ["day", "detail", date] as const,
/** `GET /api/day/index`: the window bounds are the key. */
dayIndex: (from: string, to: string) => ["day", "index", from, to] as const,
};
+36 -1
View File
@@ -24,6 +24,7 @@ import { customMetrics } from "./custom-metrics";
import { cycleKeys } from "./cycle";
import { dailyKeys } from "./daily";
import { dashboardKeys } from "./dashboard";
import { dayKeys } from "./day";
import { environmentKeys } from "./environment";
import { documentKeys } from "./documents";
import { encounterKeys } from "./encounters";
@@ -41,6 +42,7 @@ import { onboardingKeys } from "./onboarding";
import { recordSettingsKeys } from "./record-settings";
import { profileKeys } from "./profile";
import { settingsKeys } from "./settings";
import { timelineKeys } from "./timeline";
import { sharingKeys } from "./sharing";
import { vaccinationKeys } from "./vaccinations";
import { workoutKeys } from "./workouts";
@@ -76,6 +78,8 @@ export const queryKeys = {
...sharingKeys,
...vaccinationKeys,
...aiRunKeys,
...dayKeys,
...timelineKeys,
};
export { recordSettingsKeys } from "./record-settings";
@@ -151,6 +155,10 @@ export const measurementDependentKeys = [
["sleep-night"] as const,
// v1.17.0 — refresh the sleep-debt + chronotype read when sleep rows change.
["sleep-rhythm"] as const,
// v1.42 — an open day and the timeline read every record table, so a
// write to this one evicts both roots.
queryKeys.dayRoot(),
queryKeys.timelineRoot(),
];
/**
@@ -181,6 +189,10 @@ export const moodDependentKeys = [
// server-side. Call sites force an inactive refetch too (see
// `refetchInactiveDailyReads`).
queryKeys.dailyDigest(),
// v1.42 — an open day and the timeline read every record table, so a
// write to this one evicts both roots.
queryKeys.dayRoot(),
queryKeys.timelineRoot(),
];
/**
@@ -232,6 +244,10 @@ export const medicationDependentKeys = [
queryKeys.dailyDigest(),
["dashboard-medication-compliance"] as const,
["compliance-chart-inline"] as const,
// v1.42 — an open day and the timeline read every record table, so a
// write to this one evicts both roots.
queryKeys.dayRoot(),
queryKeys.timelineRoot(),
];
/**
@@ -242,7 +258,14 @@ export const medicationDependentKeys = [
* log. `insightsRoot()` rides along because phase-correlation cards depend
* on the same rows.
*/
export const cycleDependentKeys = [queryKeys.cycle(), queryKeys.insightsRoot()];
export const cycleDependentKeys = [
queryKeys.cycle(),
queryKeys.insightsRoot(),
// v1.42 — an open day and the timeline read every record table, so a
// write to this one evicts both roots.
queryKeys.dayRoot(),
queryKeys.timelineRoot(),
];
/**
* Keys invalidated when an illness episode starts, ends, is edited, deleted,
@@ -261,6 +284,10 @@ export const illnessDependentKeys = [
// v1.39.2 — a condition's body site is half of the body-site view and of
// the suggestions a body-site field offers.
queryKeys.bodySitesAll(),
// v1.42 — an open day and the timeline read every record table, so a
// write to this one evicts both roots.
queryKeys.dayRoot(),
queryKeys.timelineRoot(),
];
/**
@@ -290,6 +317,10 @@ export const encounterDependentKeys = [
// seeds its replace-set visit picker from its own cached links: left
// stale, the next tap there would delete the link just made.
queryKeys.documents(),
// v1.42 — an open day and the timeline read every record table, so a
// write to this one evicts both roots.
queryKeys.dayRoot(),
queryKeys.timelineRoot(),
];
/**
@@ -313,6 +344,10 @@ export const vaccinationDependentKeys = [
// seeds its replace-set dose picker from its own cached links: left stale,
// the next tap there would delete the link just made.
queryKeys.documents(),
// v1.42 — an open day and the timeline read every record table, so a
// write to this one evicts both roots.
queryKeys.dayRoot(),
queryKeys.timelineRoot(),
];
/**
+26
View File
@@ -0,0 +1,26 @@
/**
* Query keys — the life timeline and life events (v1.42, #613).
* Part of the centralized factory; aggregated in `./index.ts`.
*
* The timeline, its readiness inventory and the life-event list share the
* `["timeline"]` root: a life-event write changes all three, and a write to any
* other record table evicts the root through the data bundles in `./index.ts`.
*/
export const timelineKeys = {
/** Root prefix; the data-dependent bundles invalidate through it. */
timelineRoot: () => ["timeline"] as const,
/**
* `GET /api/timeline`. Zoom, window and the chosen value series are the
* key: each combination is a different payload.
*/
timeline: (
zoom: string,
from: string | null,
to: string | null,
values: string,
) => ["timeline", "lanes", zoom, from, to, values] as const,
/** `GET /api/timeline/readiness`. */
timelineReadiness: () => ["timeline", "readiness"] as const,
/** `GET /api/life-events`. */
lifeEvents: () => ["timeline", "life-events"] as const,
};
+3
View File
@@ -76,6 +76,9 @@ export const MODULE_SHARE_DOMAIN: Readonly<
// An immunization history is health background, the section `/vaccinations`
// is presented under.
vaccinations: "profile",
// The timeline lays every section side by side. Its own content, the life
// events, sits in `profile`, but the surface reads across the record.
timeline: null,
});
/** Does a grant carrying these sections open this one? `null` is the whole record. */
+5
View File
@@ -64,6 +64,11 @@ export const SHARE_DOMAINS = [
* toggle, which is a separate question from consent — a module the owner
* enabled is not a section the owner shared, and a person granting health
* background is granting the section whether or not the surfaces are on.
*
* v1.42 adds the person's life events (a move, a birth, a loss) on the same
* terms: they are background to the record rather than a reading, the consent
* copy names them in every locale, and the timeline module that shows them is
* again a separate question from consent.
*/
"profile",
/** Illness episodes and the day logs under them. */
+34
View File
@@ -72,6 +72,8 @@ import {
SecondarySymptom,
SleepStage,
VaccinationSite,
LifeEventCategory,
LifeEventPrecision,
} from "@/generated/prisma/enums";
import {
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
@@ -1213,6 +1215,36 @@ const symptomDefinitionBackupSchema = z
message: "A symptom definition needs a label or its ciphertext",
});
/**
* A life event (v1.42, #613). `title` / `titleEncrypted` and `note` /
* `noteEncrypted` are the two ends of the free-text contract: a portable file
* carries the plaintext, a disaster-recovery file the ciphertext and the
* tombstone. Dates are calendar strings.
*/
const lifeEventBackupSchema = z
.object({
id: z.string().min(1).optional(),
category: z.enum(LifeEventCategory),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
endDate: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.nullable()
.optional(),
precision: z.enum(LifeEventPrecision),
title: z.string().min(1).optional(),
titleEncrypted: base64BytesSchema.optional(),
note: z.string().nullable().optional(),
noteEncrypted: base64BytesSchema.nullable().optional(),
createdAt: isoDateTime,
updatedAt: isoDateTime,
deletedAt: isoDateTime.nullable().optional(),
})
.passthrough()
.refine((e) => e.title !== undefined || e.titleEncrypted !== undefined, {
message: "A life event needs a title or its ciphertext",
});
/**
* A practitioner, an encounter, and the edges between an encounter and the
* things it produced.
@@ -2038,6 +2070,8 @@ export const backupPayloadSchema = z
illnessEpisodes: z.array(illnessEpisodeBackupSchema).default([]),
// Defaulted: a file written before v1.40 carries no key.
symptomDefinitions: z.array(symptomDefinitionBackupSchema).default([]),
// Defaulted: a file written before v1.42 carries no key.
lifeEvents: z.array(lifeEventBackupSchema).default([]),
allergies: z.array(allergyBackupSchema).default([]),
familyHistory: z.array(familyHistoryBackupSchema).default([]),
workouts: z.array(workoutBackupSchema).default([]),