mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
363 lines
12 KiB
TypeScript
363 lines
12 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/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>;
|