Revert "feat(topics): compute topic overviews and topic pages on the server"

This reverts commit cc04e0944d.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 10:46:55 +02:00
1 parent c1911e71e3
commit 0182f91092
30 files changed
+35 -3937

No files matched your search

-1
View File
@@ -452,7 +452,6 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"tokens.measurements.ceiling_reached",
"tokens.workouts.ceiling_reached",
],
topics: ["topics.not_available", "topics.not_found", "topics.preview_off"],
vaccination: [
"vaccination.booster-invalid",
"vaccination.booster-no-antigen",
@@ -44,7 +44,6 @@ import {
} from "@/lib/validations/managed-profile-handover";
import { onboardingStateResource } from "./onboarding";
import { aiAccountBlock, moduleAccessMap } from "./profile";
import { previewFeaturesSchema } from "./settings";
import {
dataEnvelope,
errorEnvelope,
@@ -563,11 +562,6 @@ const accountPayload = z
ai: aiAccountBlock,
// v1.41 — enumerated for the reason `ai` is: the settings decide on it.
coachReasoning: coachReasoningBlock,
// Enumerated because the navigation decides on it: the Topics
// entry exists only while the person reading has switched it on. The
// caller's own opt-in, never the record's, so it does not move under a
// switch. The same object `GET /api/settings/preview-features` answers.
previewFeatures: previewFeaturesSchema,
recordSession: recordSessionState.nullable(),
// Enumerated because a client DECIDES on it rather than displays it: the
// medication surface hides the server-side reminder switch when
+2 -4
View File
@@ -77,7 +77,7 @@ const biomarkerRow = z
"A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its presence. `analyteKey` is the analyte the marker was recognised as: the catalog slug its name belongs to (e.g. `fasting-glucose`, `ldl`, `hdl`), or a key of the unit-conversion registry for an analyte with no catalog seed (e.g. `urea`, `uric-acid`), or null when its name matches none. It is derived from `name` and `unit`, never chosen: it is set on create and re-derived (or cleared) when an update changes either. A reading in another unit is converted only for a marker that has one. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds from here.",
});
export const biomarkerTrendSchema = z
const labTrend = z
.union([
z.object({
status: z.literal("ok"),
@@ -140,9 +140,7 @@ export const biomarkerTrendSchema = z
const biomarkerDetail = biomarkerRow
.extend({
trend: biomarkerTrendSchema
.optional()
.describe("v1.44 — absent on older servers."),
trend: labTrend.optional().describe("v1.44 — absent on older servers."),
})
.meta({ id: "BiomarkerDetail" });
-4
View File
@@ -56,7 +56,6 @@ import { cyclePaths } from "./cycle";
import { dashboardWidgetPaths } from "./dashboard";
import { dayPaths } from "./day";
import { searchPaths } from "./search";
import { topicPaths } from "./topics";
import { biomarkerPaths } from "./biomarkers";
import { inboundDocumentPaths } from "./documents";
import { documentSourcePaths } from "./document-sources";
@@ -205,9 +204,6 @@ const registeredPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...lifeCircumstancesPaths,
...dawarichPaths,
...relationLinkPaths,
// The topic pages, a preview each account switches on for itself
// (appended, spread order is load-bearing).
...topicPaths,
// The environmental-context overview. Its own module because nothing else
// owns the surface (appended, spread order is load-bearing).
//
+2 -2
View File
@@ -115,7 +115,7 @@ const changeVerdict = z
.enum(["improved", "worsened", "within_threshold"])
.nullable();
export const assessmentTrendSchema = z
const assessmentTrend = z
.object({
instrument: z.enum(["PHQ9", "GAD7", "WHO5"]),
n: z.number().int(),
@@ -143,7 +143,7 @@ const listResponse = z
.object({
assessments: z.array(assessmentRow),
trends: z
.array(assessmentTrendSchema)
.array(assessmentTrend)
.optional()
.describe(
"v1.44 — one per PHQ-9 / GAD-7 / WHO-5 with at least one administration. Absent on older servers.",
+2 -2
View File
@@ -72,12 +72,12 @@ const moduleListLayoutSchema = z
"Per-user presentation of the labs, illness and checkups lists. Display-only; a client that does not render a list ignores it.",
});
export const previewFeaturesSchema = z
const previewFeaturesSchema = z
.object({
topics: z
.boolean()
.describe(
"The Topics area (`/api/topics`): overview pages that bring related signals together above the single-metric Insights pages, which stay as they are. Off by default.",
"Topic overview pages above the single-metric Insights pages. Off by default.",
),
})
.meta({
-324
View File
@@ -1,324 +0,0 @@
/**
* 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(),
},
},
},
};
+2 -2
View File
@@ -122,7 +122,7 @@ const trainingLoadPoint = z.object({
),
});
export const trainingLoadSchema = z
const trainingLoad = z
.union([
z.object({
status: z.literal("ok"),
@@ -174,7 +174,7 @@ const workoutListResponse = z
offset: z.number().int().nonnegative(),
droppedDuplicates: z.number().int().nonnegative(),
}),
trainingLoad: trainingLoadSchema
trainingLoad: trainingLoad
.optional()
.describe(
"v1.44 — on the first page (`offset=0`) only, account-wide whatever the filter. Absent on later pages and older servers.",