Files
HealthLog/src/lib/openapi/routes/topics.ts
T
Marc-André Bombeck cc04e0944d feat(topics): compute topic overviews and topic pages on the server
GET /api/topics answers one entry per topic the record holds data for:
a headline value, a rule-based sentence and a 30-day sparkline.
GET /api/topics/{topic} answers a topic's signals with their usual
ranges, side-by-side series for a chosen window, the relationships
between its signals, 90-day trends, what it holds nothing for yet, and
its blocks (training load, lab markers with their trend, screener
trends, the cycle day).

Every figure comes from reads the metric pages already make: the local
daily values, the reconstructed nights, the mood score, computeLabTrend,
the training load and the screener trends. No model is involved. A
relationship says nothing below 14 paired days and is only called a
pattern at p < 0.05; a lab trend needs three readings spanning a year.

The preview is the viewer's own opt-in: with it off both routes answer
404 topics.preview_off. Topics, signals and blocks follow the record's
module switches and the sections the viewer's grant opens; the routes
read across sections, so a scoped grant is refused like the timeline.
/api/auth/me publishes the caller's opt-ins as previewFeatures.
2026-10-23 08:18:43 +02:00

325 lines
10 KiB
TypeScript

/**
* OpenAPI route table for the topic pages (preview): `/api/topics` and
* `/api/topics/{topic}`.
*
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request
* schemas are the routes' own (`@/lib/validations/topics`); the response
* shapes mirror `src/lib/topics/build.ts` and reuse the published lab-trend,
* training-load and screener-trend components rather than restating them.
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import { TOPIC_KEYS, TOPIC_RANGES } from "@/lib/topics/registry";
import {
topicDetailQuerySchema,
topicPathSchema,
} from "@/lib/validations/topics";
import { biomarkerTrendSchema } from "./biomarkers";
import { assessmentTrendSchema } from "./mental-health";
import {
dataEnvelope,
errorEnvelope,
measurementTypeEnum,
recordRefusal,
stdResponses,
} from "./shared";
import { trainingLoadSchema } from "./workouts";
const dayKey = z.string().describe("A local calendar day, `YYYY-MM-DD`.");
const topicKey = z.enum(TOPIC_KEYS).meta({
id: "TopicKey",
description: "A topic, as its URL slug.",
});
const signalKey = z.union([measurementTypeEnum, z.literal("MOOD")]).meta({
id: "TopicSignalKey",
description:
"A daily series a topic reads: a `MeasurementType`, or `MOOD` for the day's mean mood score (1 to 5). Values are in the type's stored unit (sleep in minutes asleep per night, keyed by the day the night ended); clients convert for display as they do for the metric pages.",
});
const dayValue = z.object({ day: dayKey, value: z.number() });
const usualRange = z
.object({
low: z.number(),
high: z.number(),
center: z.number(),
days: z.number().int().describe("Days the band was built from."),
})
.meta({
id: "TopicUsualRange",
description:
"The personal usual range over the trailing 90 days: median ± 3 scaled MAD of the daily values, never narrower than the measurement's own wobble. Needs 7 days, else null.",
});
const signal = z
.object({
key: signalKey,
latest: dayValue.nullable(),
usual: usualRange.nullable(),
state: z
.enum(["within", "above", "below", "learning", "none"])
.describe(
"`latest` against `usual`: `learning` while the range needs more days, `none` without a value.",
),
href: z
.string()
.nullable()
.describe("The in-app single-metric page of this signal."),
})
.meta({ id: "TopicSignal" });
const summary = z
.object({
code: z.enum([
"outside",
"labs",
"cycleDay",
"steady",
"learning",
"empty",
]),
signal: signalKey.nullable(),
direction: z.enum(["above", "below"]).nullable(),
count: z.number().int().nullable(),
total: z.number().int().nullable(),
})
.meta({
id: "TopicSummary",
description:
"The topic's one sentence as a code with parameters, picked by a fixed rule (never model text): `outside` names the first signal outside its usual range and the direction; `labs` counts lab markers inside their window (`count` of `total`); `cycleDay` gives today's cycle day in `count`; `steady` counts signals inside their range (`count` of `total` recorded); `learning` while no range is established; `empty` without any data.",
});
const headline = z
.discriminatedUnion("kind", [
z.object({
kind: z.literal("signal"),
key: signalKey,
value: z.number(),
day: dayKey,
}),
z.object({
kind: z.literal("labs"),
inRange: z.number().int(),
total: z.number().int(),
}),
z.object({ kind: z.literal("cycleDay"), dayOfCycle: z.number().int() }),
z.object({
kind: z.literal("screener"),
instrument: z.string(),
score: z.number(),
}),
])
.meta({ id: "TopicHeadline" });
const overviewEntry = z
.object({
key: topicKey,
headline: headline.nullable(),
summary,
tone: z.enum(["steady", "watch", "learning"]),
sparkline: z
.array(z.number())
.describe(
"The headline signal's daily values over the last 30 days, oldest first; empty when the headline is a block.",
),
recorded: z.number().int(),
total: z.number().int(),
})
.meta({ id: "TopicOverviewEntry" });
const overviewSchema = z
.object({
topics: z.array(overviewEntry),
empty: z
.array(topicKey)
.describe("Offered topics with nothing recorded yet."),
hiddenByModule: z
.array(topicKey)
.describe("Topics whose every module is switched off for the record."),
})
.meta({ id: "TopicOverview" });
const relationship = z
.object({
id: z.string(),
x: signalKey,
y: signalKey,
lagDays: z
.union([z.literal(0), z.literal(1)])
.describe("`x` on day d is held against `y` on day d + `lagDays`."),
kind: z.enum(["correlation", "split"]),
pairedDays: z.number().int(),
requiredDays: z
.number()
.int()
.describe("Paired days needed before anything is said: 14."),
status: z
.enum(["insufficient", "no_clear_link", "linked"])
.describe(
"`linked` only at p < 0.05 (Pearson, or Welch for a split); a pattern, never a cause.",
),
r: z.number().nullable(),
pValue: z.number().nullable(),
split: z
.object({
threshold: z.number(),
belowDays: z.number().int(),
otherDays: z.number().int(),
belowMean: z.number(),
otherMean: z.number(),
difference: z.number(),
})
.nullable()
.describe(
"For a split: the mean of `y` on days `x` sat below `threshold` against the other days.",
),
points: z
.array(z.object({ x: z.number(), y: z.number() }))
.describe(
"Paired values for a scatter, at most 120; empty when insufficient.",
),
})
.meta({ id: "TopicRelationship" });
const trend = z
.object({
key: signalKey,
status: z.enum(["ok", "insufficient"]),
days: z.number().int(),
change: z
.number()
.nullable()
.describe(
"The least-squares change across the trailing 90 days, in the stored unit. Needs 14 days.",
),
weekly: z
.array(dayValue)
.describe("Weekly means, each keyed by its Monday."),
})
.meta({ id: "TopicTrend" });
const labMarker = z
.object({
biomarkerId: z.string(),
name: z.string(),
unit: z.string(),
latest: z.number(),
takenOn: dayKey,
low: z.number().nullable(),
high: z.number().nullable(),
status: z.enum(["in-range", "below", "above", "unknown"]),
readings: z.number().int(),
trend: biomarkerTrendSchema,
})
.meta({ id: "TopicLabMarker" });
const labPanel = z
.object({
markers: z.array(labMarker).describe("At most 24, newest reading first."),
inRange: z.number().int(),
withRange: z.number().int(),
latestOn: dayKey.nullable(),
})
.meta({ id: "TopicLabPanel" });
const cycleSummary = z
.object({
today: z
.object({
dayOfCycle: z.number().int(),
phase: z.string().nullable(),
})
.nullable(),
lengths: z.array(z.number().int()),
averageLength: z.number().nullable(),
periodsLogged: z.number().int(),
})
.meta({ id: "TopicCycleSummary" });
const detailSchema = z
.object({
key: topicKey,
range: z.enum(TOPIC_RANGES),
from: dayKey,
to: dayKey,
status: z.enum(["ready", "partial", "empty"]),
summary,
signals: z.array(signal),
series: z.array(
z.object({
key: signalKey,
usual: usualRange.nullable(),
points: z.array(dayValue),
}),
),
relationships: z.array(relationship),
trends: z.array(trend),
missing: z
.array(signalKey)
.describe("Signals the topic could show that hold no value yet."),
labs: labPanel.nullable(),
training: trainingLoadSchema.nullable(),
screeners: z.array(assessmentTrendSchema).nullable(),
cycle: cycleSummary.nullable(),
})
.meta({ id: "TopicDetail" });
const PREVIEW_OFF =
"`topics.preview_off`: the person reading has not switched the topics preview on (Settings > System > Preview features). The preview is per account and off by default.";
export const topicPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/topics": {
get: {
tags: ["Insights"],
summary: "Topic overview (preview)",
description:
"One entry per topic the record holds data for, with a headline value, a rule-based sentence and a 30-day sparkline, computed from the same daily values the metric pages draw. No model, no AI gate. Topics follow the record's module switches. Reads across sections, so a grant scoped to some of them is refused.",
responses: {
"200": {
description: "The overview.",
content: {
"application/json": {
schema: dataEnvelope(overviewSchema, "TopicOverviewEnvelope"),
},
},
},
"404": {
description: PREVIEW_OFF,
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
...recordRefusal(),
},
},
},
"/api/topics/{topic}": {
get: {
tags: ["Insights"],
summary: "One topic (preview)",
description:
"A topic's signals with their usual ranges, the side-by-side series for `range`, relationships between its signals (nothing is said below 14 paired days), 90-day trends, the signals it holds nothing for yet, and its blocks: training load, lab markers with their trend (three readings spanning a year), screener trends, today's cycle day. No model, no AI gate.",
requestParams: { path: topicPathSchema, query: topicDetailQuerySchema },
responses: {
"200": {
description: "The topic.",
content: {
"application/json": {
schema: dataEnvelope(detailSchema, "TopicDetailEnvelope"),
},
},
},
"404": {
description: `${PREVIEW_OFF}\n\n\`topics.not_found\`: no topic by that name. \`topics.not_available\`: the record's module switches do not offer this topic.`,
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
...recordRefusal(),
},
},
},
};