Merge branch 'feat/v144-analysis' into release/v1.44.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-21 22:27:59 +02:00
commit 67d04630c3
74 files changed
+4633 -120

No files matched your search

+54 -2
View File
@@ -77,6 +77,58 @@ 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.",
});
const labTrend = z
.union([
z.object({
status: z.literal("ok"),
n: z.number().int(),
firstAt: z.string().describe("YYYY-MM-DD of the first reading."),
lastAt: z.string().describe("YYYY-MM-DD of the newest reading."),
spanDays: z.number().int(),
slopePerYear: z
.number()
.describe("Least-squares change per year, in the marker's unit."),
slopeCi95: z
.tuple([z.number(), z.number()])
.describe("95 % interval of the slope (Student t at n − 2 df)."),
pValue: z.number(),
fittedNow: z.number().describe("The line's value on `asOf`."),
asOf: z.string().describe("YYYY-MM-DD the line was evaluated on."),
projection: z.enum([
"exits",
"beyond_horizon",
"no_exit",
"already_outside",
"no_clear_trend",
"no_band",
]),
exitDate: z
.string()
.nullable()
.describe(
"Only for `exits`: the day the straight line meets `exitBound`. A projection, never a forecast.",
),
exitBound: z.enum(["low", "high"]).nullable(),
}),
z.object({
status: z.literal("insufficient"),
reason: z.enum(["too_few_values", "span_too_short"]),
n: z.number().int(),
spanDays: z.number().int(),
}),
])
.meta({
id: "BiomarkerTrend",
description:
"v1.44 — the least-squares line through every numeric reading of the marker (all stored in the marker's unit). Needs at least three readings spanning at least 365 days, else `insufficient`. `projection` places the line against the marker's band: `exits` (with `exitDate`) only when the slope's 95 % interval excludes zero, the line is inside the band today and reaches a bound within three years. Server-computed; clients render it as given.",
});
const biomarkerDetail = biomarkerRow
.extend({
trend: labTrend.optional().describe("v1.44 — absent on older servers."),
})
.meta({ id: "BiomarkerDetail" });
const listResponse = z
.object({ biomarkers: z.array(biomarkerRow) })
.meta({ id: "ListBiomarkersResponse" });
@@ -191,7 +243,7 @@ export const biomarkerPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Labs"],
summary: "Fetch a single biomarker",
description:
"Returns the biomarker including its decrypted `context` and, since v1.43, its `aliases`. Cross-user rows surface as 404. A marker merged into another answers 404 with `meta.mergedInto`.",
"Returns the biomarker including its decrypted `context`, since v1.43 its `aliases`, and since v1.44 its `trend` over every stored reading. Cross-user rows surface as 404. A marker merged into another answers 404 with `meta.mergedInto`.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
...recordRefusal(),
@@ -199,7 +251,7 @@ export const biomarkerPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "Biomarker detail.",
content: {
"application/json": {
schema: dataEnvelope(biomarkerRow, "GetBiomarkerResponse"),
schema: dataEnvelope(biomarkerDetail, "GetBiomarkerResponse"),
},
},
},
@@ -44,6 +44,16 @@ const healthStatusResponse = z
beforeMean: z.number(),
afterMean: z.number(),
direction: z.enum(["up", "down"]),
beforeDays: z
.number()
.int()
.optional()
.describe("v1.44 — days with a value before the break."),
afterDays: z
.number()
.int()
.optional()
.describe("v1.44 — days with a value from the break on."),
}),
),
generatedAt: z.string().describe("ISO-8601 instant the read was computed."),
+41 -1
View File
@@ -100,8 +100,48 @@ const createResponse = z
})
.meta({ id: "CreateMentalHealthAssessmentResponse" });
const scorePoint = z.object({
score: z.number(),
takenAt: z.string(),
});
const changeVerdict = z
.enum(["improved", "worsened", "within_threshold"])
.nullable();
const assessmentTrend = z
.object({
instrument: z.enum(["PHQ9", "GAD7", "WHO5"]),
n: z.number().int(),
threshold: z
.number()
.describe(
"Meaningful-change threshold in reported points: PHQ-9 5, GAD-7 4, WHO-5 10 (0–100 scale).",
),
higherIsBetter: z.boolean(),
first: scorePoint,
previous: scorePoint.nullable(),
latest: scorePoint,
changeFromPrevious: z.number().nullable(),
changeFromFirst: z.number().nullable(),
sincePrevious: changeVerdict,
sinceFirst: changeVerdict,
})
.meta({
id: "MentalHealthAssessmentTrend",
description:
"v1.44 — the latest total against the previous and the first administration, classified against the instrument's meaningful-change threshold in its own direction. A change below the threshold is `within_threshold`. Server-computed over the whole history, not the page.",
});
const listResponse = z
.object({ assessments: z.array(assessmentRow) })
.object({
assessments: z.array(assessmentRow),
trends: z
.array(assessmentTrend)
.optional()
.describe(
"v1.44 — one per PHQ-9 / GAD-7 / WHO-5 with at least one administration. Absent on older servers.",
),
})
.meta({ id: "ListMentalHealthAssessmentsResponse" });
const detailResponse = z
+46
View File
@@ -110,6 +110,47 @@ const workoutListEntry = z
})
.meta({ id: "WorkoutListEntry" });
const trainingLoadPoint = z.object({
day: z.string(),
ctl: z.number(),
atl: z.number(),
tsb: z.number(),
});
const trainingLoad = z
.union([
z.object({
status: z.literal("ok"),
asOf: z
.string()
.describe("The last scored UTC day the figures describe."),
ctl: z.number().describe("Fitness: 42-day exponentially weighted TRIMP."),
atl: z.number().describe("Fatigue: 7-day exponentially weighted TRIMP."),
tsb: z.number().describe("Form: fitness minus fatigue at `asOf`."),
daysCovered: z.number().int(),
trainingDays: z.number().int(),
warmingUp: z
.boolean()
.describe("True below 42 covered days: both averages start at zero."),
series: z
.array(trainingLoadPoint)
.describe(
"The trailing 90 days, oldest first; `tsb` is form going into the day.",
),
}),
z.object({
status: z.literal("insufficient"),
reason: z.enum(["no_impulse_history", "too_little_history"]),
daysCovered: z.number().int(),
trainingDays: z.number().int(),
}),
])
.meta({
id: "WorkoutTrainingLoad",
description:
"v1.44 — the fitness-fatigue model over the stored daily heart-rate training impulse (Banister TRIMP; a day without a stored impulse counts as rest). Needs 28 days and three training days. TRIMP units: comparable with the person's own past only.",
});
const workoutListResponse = z
.object({
workouts: z.array(workoutListEntry),
@@ -119,6 +160,11 @@ const workoutListResponse = z
offset: z.number().int().nonnegative(),
droppedDuplicates: z.number().int().nonnegative(),
}),
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.",
),
})
.meta({ id: "WorkoutListResponse" });