Files
HealthLog/src/lib/day/contract.ts
T
Marc-André Bombeck 42b2c72e28 feat(timeline): serve lab markers as value lines
The timeline showed a lab report as an event on the axis, but the lines
under the lanes could only be measurements. A lab marker can now be named
in `values` as `lab:<biomarkerId>`, beside the measurement types and
`MOOD`, so one choice, one request and one stored list carry every line.

`GET /api/timeline` answers with `labSeries`, one entry per named marker
with its readings in the window as single points (lab values come a few a
year, so they are never averaged into buckets), each dated by the record's
local day, judged against its own window and linked to the report it was
read from when the caller may see documents. `availableLabs` lists every
marker the record has a numeric reading for, so a client offers only
those.

A reading stored in another unit than its marker's is converted through
the unit-conversion registry where the pair is known, and otherwise left
out and counted in `excluded`, never drawn on a scale it is not in. A
marker id that is not in the record yields no line instead of a 422, so a
stored choice survives a deleted marker; ids are capped at 24 a request.

Refs #1299
2026-10-29 20:31:27 +01:00

468 lines
17 KiB
TypeScript

/**
* 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/day/notable what changed since the last visit
* 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,
dayNotableResponseSchema,
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
* scores the day's scores (health score, readiness, recovery, sleep
* score, strain), each from its own stored or computed series
* 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
* lifeEvents life events dated that day at DAY precision, or spanning it
*/
export const DAY_SECTION_KEYS = [
"values",
"sleep",
"scores",
"mood",
"assessments",
"medications",
"illness",
"symptoms",
"allergies",
"labs",
"visits",
"vaccinations",
"checkups",
"documents",
"workouts",
"cycle",
"environment",
"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[];
/**
* The scores a day can carry, in the order the view shows them. Each is
* present only when the record holds it for that day; a missing score is
* left out, never sent as a null.
*
* healthScore the health score as recorded on that local day
* readiness the readiness blend persisted by the nightly job for the
* night that ended on this morning
* recovery a device's own recovery for that morning (WHOOP, Oura,
* Polar); the computed proxy is the readiness above and is
* not sent twice
* sleepScore the sleep score of the night that ended on this morning
* strain the computed strain of the day, or the device's day strain
* on its own scale when no computed one exists
*/
export const DAY_SCORE_KEYS = [
"healthScore",
"readiness",
"recovery",
"sleepScore",
"strain",
] as const;
export type DayScoreKey = (typeof DAY_SCORE_KEYS)[number];
/** The page each score opens on. */
export const DAY_SCORE_HREF: Readonly<Record<DayScoreKey, string>> = {
healthScore: "/insights/health-score",
readiness: "/insights/scores/readiness",
recovery: "/insights/scores/recovery",
sleepScore: "/insights/scores/sleep",
strain: "/insights/scores/strain",
};
/** 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: a record
* with a start of its own (a medication, a course, a pause, an illness, a
* cycle, a trip, a life event that spans days). Profile facts such as smoking
* status or a shift pattern are not here: they describe the person, they
* hold no start the person gave, and counting "day n" from when they were
* filed would state a duration nobody recorded.
*/
export const DAY_RUNNING_KINDS = [
"medication",
"medicationCourse",
"medicationPause",
"illness",
"restMode",
"cyclePhase",
"travel",
"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.
* `range` is a stretch the person chose, named by `from` and `to` (both
* required, at most {@link TIMELINE_RANGE_MAX_DAYS} days).
*/
export const TIMELINE_ZOOMS = ["all", "year", "quarter", "range"] as const;
export type TimelineZoom = (typeof TIMELINE_ZOOMS)[number];
/**
* The span one value-series point averages. The server picks it per zoom
* (`timelineBucket` in `src/lib/timeline/load-timeline.ts`): quarters for a
* multi-year `all`, months for `year` and a short `all`, weeks for `quarter`,
* and for a `range` by its length, down to single days under six weeks.
*/
export const TIMELINE_BUCKETS = ["quarter", "month", "week", "day"] as const;
/**
* The longest `range` the timeline answers: fifteen years. Longer than any
* fixed zoom reads, and the series of such a window come from the rollup
* tier in quarters, so the cost stays that of an `all` view.
*/
export const TIMELINE_RANGE_MAX_DAYS = 15 * 366;
export type TimelineBucket = (typeof TIMELINE_BUCKETS)[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; every
* item of one medication carries its id as `group`, and a
* client draws a group on one row
* 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];
export type DayScore = DayResponse["scores"][number];
/** `GET /api/day/index` → `data`. */
export type DayIndexResponse = z.infer<typeof dayIndexResponseSchema>;
/**
* `GET /api/day/notable` → `data`: the observations and context changes
* since the last visit, for the visit preparation.
*/
export type DayNotableWindowResponse = z.infer<typeof dayNotableResponseSchema>;
export type DayChange = DayNotableWindowResponse["changes"][number];
/** Widest window `GET /api/day/notable` answers, in days. */
export const DAY_NOTABLE_MAX_SPAN_DAYS = 1096;
/** `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];
export type TimelineLabSeries = TimelineResponse["labSeries"][number];
export type TimelineLabPoint = TimelineLabSeries["points"][number];
export type TimelineLabMarker = TimelineResponse["availableLabs"][number];
/**
* A lab marker's line is named in `values` as `lab:<biomarkerId>` (#1299),
* beside the measurement types and `MOOD`, so one choice, one request and
* one stored list carry every line under the lanes.
*/
export const TIMELINE_LAB_KEY_PREFIX = "lab:";
/**
* The most lab markers one request names. Measurement names are a closed
* set and bound themselves; marker ids are not.
*/
export const TIMELINE_LAB_SERIES_MAX = 24;
/** The marker id a `values` entry names, or null when it names no marker. */
export function timelineLabMarkerId(key: string): string | null {
if (!key.startsWith(TIMELINE_LAB_KEY_PREFIX)) return null;
const id = key.slice(TIMELINE_LAB_KEY_PREFIX.length);
return /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : null;
}
/** The `values` entry for a lab marker. */
export function timelineLabKey(biomarkerId: string): string {
return `${TIMELINE_LAB_KEY_PREFIX}${biomarkerId}`;
}
/** `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>;