feat: serve the day, its index, the timeline and life events

Replace the day, timeline and life-event stubs with their implementations
(#613) and drop the not-implemented helper, its error code and its OpenAPI
response with the last stub.

loadDay reads one local day for every reader: the readings live inside the
record's own day window (never the UTC rollups), through the canonical-source
engine the mood sheet already uses (resolveDayReadings in linked-context.ts),
with the person's usual range over the 30 days before; the record sections
from their own tables, each in the storage form it speaks (an instant in the
local window, a date string with its own zone, a calendar date). A switched-off
module's section is not read; a section the grant does not cover is named
not_shared and not read.

The day, the day index and the new visit preparation (GET /api/day/notable)
are admitted on the readings and narrowed per section through
actingDomainVisibility, so a scoped grant sees exactly its sections. The
timeline and its readiness stay whole-record reads and narrow their lanes the
same way.

Notable days are deterministic keys: a three-month high or low with history
behind it, a first reading, and in the visit preparation a regular reading
that stopped. The timeline lays out spans and points from the owning tables,
standing items without a date, and series by zoom: months from the rollup
tier inside its five-year window and from one cached live query before it.
The readiness inventory rates each lane with one link per gap.

Life events are created, edited and soft-deleted as MANAGE verbs with audit
rows, encrypted at rest, and join the sharing guard and the handler matrix.
get_day reads a day for the Coach and the MCP endpoint without life events,
notes or document names; its step carries the date it read.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 14:31:13 +02:00
1 parent b8f6372963
commit 0924285cd6
65 files changed
+7642 -209

No files matched your search

+31 -7
View File
@@ -1,18 +1,42 @@
/**
* `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`.
* The shape is `TimelineReadinessResponse` in `src/lib/day/contract.ts`;
* `loadTimelineReadiness` builds it.
*
* Gated on the opt-in `timeline` module. Contract stub: answers 501 behind
* its admission and the gate.
* Gated on the opt-in `timeline` module, admitted like the timeline, and
* narrowed the same way: a lane the grant does not cover is not listed.
*/
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { apiSuccess } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate";
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
import { loadTimelineReadiness } from "@/lib/timeline/readiness";
import { resolveUserTimezone } from "@/lib/tz/resolver";
export const GET = apiHandler(async () => {
const { user } = await requireRecordAuth("read", "record");
const { user, grantId } = await requireRecordAuth("read", "record");
const gate = await requireModuleEnabled(user.id, "timeline");
if (!gate.enabled) return gate.response;
return notImplemented();
const [modules, domainVisible, tz] = await Promise.all([
resolveModuleMap(user.id),
actingDomainVisibility(prisma, grantId),
resolveUserTimezone(user.id),
]);
const readiness = await loadTimelineReadiness({
recordId: user.id,
tz,
access: { modules, domainVisible, owner: grantId === null },
});
annotate({
action: { name: "timeline.readiness.read" },
meta: {
verdict: readiness.verdict,
lanes: readiness.lanes.length,
carrying: readiness.lanes.filter((l) => l.status === "carries").length,
},
});
return apiSuccess(readiness);
});
+66 -10
View File
@@ -1,20 +1,76 @@
/**
* `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`.
* life events, the standing items without a start, and value series by zoom.
* The shape is `TimelineResponse` in `src/lib/day/contract.ts`; `loadTimeline`
* builds it.
*
* 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.
* Gated on the opt-in `timeline` module. Admission declares the whole record:
* the timeline lays every section side by side, so a grant scoped to some of
* them does not open it. The lanes are narrowed to the grant's domains all
* the same (`actingDomainVisibility`), so the day this admission is reviewed
* and narrowed the loader already reads no lane it should not.
*/
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { timelineQuerySchema } from "@/lib/day/wire-schemas";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate";
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
import { loadTimeline, parseSeriesKeys } from "@/lib/timeline/load-timeline";
import { resolveUserTimezone } from "@/lib/tz/resolver";
export const GET = apiHandler(async () => {
const { user } = await requireRecordAuth("read", "record");
export const GET = apiHandler(async (request: Request) => {
const { user, grantId } = await requireRecordAuth("read", "record");
const gate = await requireModuleEnabled(user.id, "timeline");
if (!gate.enabled) return gate.response;
return notImplemented();
const params = new URL(request.url).searchParams;
const parsed = timelineQuerySchema.safeParse({
zoom: params.get("zoom") ?? undefined,
from: params.get("from") ?? undefined,
to: params.get("to") ?? undefined,
values: params.get("values") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "timeline.invalid",
});
}
if (parsed.data.from && parsed.data.to && parsed.data.from > parsed.data.to) {
return apiError("`from` must not be after `to`", 422, {
errorCode: "timeline.invalid",
});
}
const seriesKeys = parseSeriesKeys(parsed.data.values);
if (seriesKeys === null) {
return apiError("Unknown value series", 422, {
errorCode: "timeline.invalid",
});
}
const [modules, domainVisible, tz] = await Promise.all([
resolveModuleMap(user.id),
actingDomainVisibility(prisma, grantId),
resolveUserTimezone(user.id),
]);
const timeline = await loadTimeline({
recordId: user.id,
query: parsed.data,
seriesKeys,
access: { modules, domainVisible, owner: grantId === null },
tz,
});
annotate({
action: { name: "timeline.read" },
meta: {
zoom: timeline.zoom,
lanes: timeline.lanes.length,
items: timeline.lanes.reduce((n, l) => n + l.items.length, 0),
series: timeline.series.length,
notable: timeline.notable.length,
},
});
return apiSuccess(timeline);
});