mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Revert "feat(topics): compute topic overviews and topic pages on the server"
This reverts commit cc04e0944d.
This commit is contained in:
1 parent
c1911e71e3
commit
0182f91092
30 files changed
+35
-3937
No files matched your search
@@ -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
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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).
|
||||
//
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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.",
|
||||
|
||||
Reference in new issue
Block a user