Files
HealthLog/src/lib/topics/build.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

387 lines
11 KiB
TypeScript

/**
* Assemble the topic overview and a topic page from what `read.ts` returned.
* Pure: every input arrives as an argument, so a fixture covers every state.
*
* The summary sentence is a rule, never model text: the first signal outside
* its usual range names itself; otherwise the lab panel or the cycle day
* speaks for its topic; otherwise the count of signals inside their range;
* otherwise "still learning". The client words the code; the server only
* decides which one applies.
*/
import { insightsHrefForMetric } from "@/lib/insights/metric-deep-link";
import type { AssessmentTrend } from "@/lib/mental-health/assessment-trend";
import type { TrainingLoad } from "@/lib/insights/training-load";
import { shiftDateKey } from "@/lib/tz/format";
import type { TopicLabPanel } from "./labs";
import type { TopicCycleSummary, TopicInputs } from "./read";
import {
MOOD_SIGNAL,
SPARKLINE_POINTS,
TOPIC_BASE_WINDOW_DAYS,
TOPIC_RANGE_DAYS,
TOPICS,
type TopicDefinition,
type TopicKey,
type TopicRange,
type TopicSignalKey,
} from "./registry";
import {
computeRelationship,
computeTrend,
latestValue,
stateAgainstRange,
usualRange,
windowValues,
type DayValue,
type SignalState,
type TopicRelationship,
type TopicTrend,
type UsualRange,
} from "./stats";
import {
isExtraVisible,
isSignalVisible,
visibleSignals,
type TopicVisibility,
} from "./visibility";
export interface TopicSignal {
key: TopicSignalKey;
latest: DayValue | null;
usual: UsualRange | null;
state: SignalState;
/** The single-metric page this signal opens, when it has one. */
href: string | null;
}
export type TopicSummaryCode =
"outside" | "labs" | "cycleDay" | "steady" | "learning" | "empty";
export interface TopicSummary {
code: TopicSummaryCode;
signal: TopicSignalKey | null;
direction: "above" | "below" | null;
count: number | null;
total: number | null;
}
export type TopicHeadline =
| { kind: "signal"; key: TopicSignalKey; value: number; day: string }
| { kind: "labs"; inRange: number; total: number }
| { kind: "cycleDay"; dayOfCycle: number }
| { kind: "screener"; instrument: string; score: number };
export type TopicTone = "steady" | "watch" | "learning";
export interface TopicOverviewEntry {
key: TopicKey;
headline: TopicHeadline | null;
summary: TopicSummary;
tone: TopicTone;
/** The headline signal's last values, oldest first (empty for a block headline). */
sparkline: number[];
/** Signals of the topic's list that hold a value. */
recorded: number;
/** Signals of the topic's list the record could hold. */
total: number;
}
export interface TopicOverview {
topics: TopicOverviewEntry[];
/** Offered topics with nothing recorded yet. */
empty: TopicKey[];
/** Topics whose every module is switched off. */
hiddenByModule: TopicKey[];
}
export interface TopicSeries {
key: TopicSignalKey;
usual: UsualRange | null;
points: DayValue[];
}
export interface TopicDetail {
key: TopicKey;
range: TopicRange;
from: string;
to: string;
status: "ready" | "partial" | "empty";
summary: TopicSummary;
signals: TopicSignal[];
series: TopicSeries[];
relationships: TopicRelationship[];
trends: TopicTrend[];
/** Signals the topic could show that hold no value yet. */
missing: TopicSignalKey[];
labs: TopicLabPanel | null;
training: TrainingLoad | null;
screeners: AssessmentTrend[] | null;
cycle: TopicCycleSummary | null;
}
function signalHref(key: TopicSignalKey): string | null {
if (key === MOOD_SIGNAL) return "/insights/mood";
return insightsHrefForMetric(key);
}
export function buildSignals(
keys: readonly TopicSignalKey[],
inputs: TopicInputs,
todayKey: string,
): TopicSignal[] {
return keys.map((key) => {
const series = inputs.series.get(key);
const latest = latestValue(series, todayKey);
const usual = usualRange(key, series, todayKey);
return {
key,
latest,
usual,
state: stateAgainstRange(latest, usual),
href: signalHref(key),
};
});
}
function hasBlockData(inputs: TopicInputs): boolean {
return (
inputs.labs !== null ||
(inputs.screeners !== null && inputs.screeners.length > 0) ||
inputs.cycle !== null ||
(inputs.training !== null && inputs.training.status === "ok")
);
}
/** The rule that picks the topic's one sentence. */
export function summarise(
signals: readonly TopicSignal[],
inputs: TopicInputs,
): TopicSummary {
const none: TopicSummary = {
code: "empty",
signal: null,
direction: null,
count: null,
total: null,
};
const recorded = signals.filter((s) => s.latest !== null);
if (recorded.length === 0 && !hasBlockData(inputs)) return none;
const outside = recorded.find(
(s) => s.state === "above" || s.state === "below",
);
if (outside) {
return {
...none,
code: "outside",
signal: outside.key,
direction: outside.state === "above" ? "above" : "below",
};
}
if (inputs.labs && inputs.labs.withRange > 0) {
return {
...none,
code: "labs",
count: inputs.labs.inRange,
total: inputs.labs.withRange,
};
}
if (inputs.cycle?.today) {
return { ...none, code: "cycleDay", count: inputs.cycle.today.dayOfCycle };
}
const within = recorded.filter((s) => s.state === "within");
if (within.length > 0) {
return {
...none,
code: "steady",
count: within.length,
total: recorded.length,
};
}
return { ...none, code: "learning" };
}
function toneOf(summary: TopicSummary, labs: TopicLabPanel | null): TopicTone {
if (summary.code === "outside") return "watch";
if (summary.code === "labs") {
return labs && labs.inRange < labs.withRange ? "watch" : "steady";
}
if (summary.code === "steady" || summary.code === "cycleDay") return "steady";
return "learning";
}
function headlineOf(
signals: readonly TopicSignal[],
inputs: TopicInputs,
): TopicHeadline | null {
const first = signals.find((s) => s.latest !== null);
if (first?.latest) {
return {
kind: "signal",
key: first.key,
value: first.latest.value,
day: first.latest.day,
};
}
if (inputs.labs) {
return {
kind: "labs",
inRange: inputs.labs.inRange,
total: inputs.labs.withRange,
};
}
if (inputs.cycle?.today) {
return { kind: "cycleDay", dayOfCycle: inputs.cycle.today.dayOfCycle };
}
const screener = inputs.screeners?.[0];
if (screener) {
return {
kind: "screener",
instrument: screener.instrument,
score: screener.latest.score,
};
}
return null;
}
/** The topic's slice of the shared read, with blocks it may not show removed. */
export function inputsForTopic(
def: TopicDefinition,
inputs: TopicInputs,
ctx: TopicVisibility,
): TopicInputs {
const allowed = (extra: "labs" | "training" | "screeners" | "cycle") =>
def.extras.includes(extra) && isExtraVisible(extra, ctx);
return {
series: inputs.series,
labs: allowed("labs") ? inputs.labs : null,
training: allowed("training") ? inputs.training : null,
screeners: allowed("screeners") ? inputs.screeners : null,
cycle: allowed("cycle") ? inputs.cycle : null,
};
}
export function buildOverviewEntry(
def: TopicDefinition,
inputs: TopicInputs,
ctx: TopicVisibility,
todayKey: string,
): TopicOverviewEntry | null {
const own = inputsForTopic(def, inputs, ctx);
const signals = buildSignals(visibleSignals(def, ctx), own, todayKey);
const summary = summarise(signals, own);
if (summary.code === "empty") return null;
const headline = headlineOf(signals, own);
const sparkline =
headline?.kind === "signal"
? windowValues(
own.series.get(headline.key),
shiftDateKey(todayKey, -(SPARKLINE_POINTS - 1)),
todayKey,
).map((p) => p.value)
: [];
return {
key: def.key,
headline,
summary,
tone: toneOf(summary, own.labs),
sparkline,
recorded: signals.filter((s) => s.latest !== null).length,
total: signals.length,
};
}
export function buildOverview(
offered: readonly TopicKey[],
hiddenByModule: readonly TopicKey[],
inputs: TopicInputs,
ctx: TopicVisibility,
todayKey: string,
): TopicOverview {
const topics: TopicOverviewEntry[] = [];
const empty: TopicKey[] = [];
for (const key of offered) {
const entry = buildOverviewEntry(TOPICS[key], inputs, ctx, todayKey);
if (entry) topics.push(entry);
else empty.push(key);
}
return { topics, empty, hiddenByModule: [...hiddenByModule] };
}
/** First day of the read a topic page needs for its range. */
export function detailReadStart(range: TopicRange, todayKey: string): string {
const days = Math.max(TOPIC_RANGE_DAYS[range], TOPIC_BASE_WINDOW_DAYS);
return shiftDateKey(todayKey, -days);
}
export function buildDetail(
def: TopicDefinition,
range: TopicRange,
inputs: TopicInputs,
ctx: TopicVisibility,
todayKey: string,
): TopicDetail {
const own = inputsForTopic(def, inputs, ctx);
const signalKeys = visibleSignals(def, ctx);
const signals = buildSignals(signalKeys, own, todayKey);
const summary = summarise(signals, own);
const from = shiftDateKey(todayKey, -(TOPIC_RANGE_DAYS[range] - 1));
const pairFrom = detailReadStart(range, todayKey);
const series: TopicSeries[] = [];
for (const key of def.chart) {
if (!isSignalVisible(key, ctx)) continue;
const values = own.series.get(key);
const points = windowValues(values, from, todayKey);
if (points.length === 0) continue;
series.push({ key, usual: usualRange(key, values, todayKey), points });
}
// A pair shows when both sides may be shown and both hold values: that is
// when "needs 14 paired days, you have n" tells the reader something. A
// pair with a switched-off or never-recorded side is not named.
const relationships = def.relationships
.filter((rel) => isSignalVisible(rel.x, ctx) && isSignalVisible(rel.y, ctx))
.filter((rel) => own.series.has(rel.x) && own.series.has(rel.y))
.map((rel) =>
computeRelationship(
rel,
own.series.get(rel.x),
own.series.get(rel.y),
pairFrom,
todayKey,
),
);
const trends = signals
.filter((s) => s.latest !== null)
.map((s) => computeTrend(s.key, own.series.get(s.key), todayKey));
const missing = signals.filter((s) => s.latest === null).map((s) => s.key);
const status =
summary.code === "empty"
? "empty"
: missing.length > 0
? "partial"
: "ready";
return {
key: def.key,
range,
from,
to: todayKey,
status,
summary,
signals,
series,
relationships,
trends,
missing,
labs: own.labs,
training: own.training,
screeners: own.screeners,
cycle: own.cycle,
};
}