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

@@ -0,0 +1,104 @@
/**
* The statistics behind the correlation, trend and changepoint surfaces are
* computed without a model.
*
* These modules answer "what does the record show": correlation channels,
* lab slopes and projected band exits, screener change against the
* clinically meaningful threshold, training load, level shifts. None of that
* may depend on a provider being configured, and none of it may become a way
* to reach one. A capability gate cannot protect a computation that quietly
* imports the provider chain, so this guard walks each module's import
* closure (value imports, `@/` and relative, through re-exports) and fails on
* anything under `lib/ai/` except the byte cipher the symptom decrypt uses,
* and on any provider call in the module's own text.
*
* Limits, stated: a dynamic `import()` or a `require` is not followed, and a
* type-only import is skipped by design (it carries no code). The list below
* is the set this guard covers; a new analysis module joins it by being added.
*/
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
const SRC = join(process.cwd(), "src");
/** The analysis modules held to "no model anywhere below". */
const ANALYSIS_MODULES = [
"lib/insights/correlation-discovery.ts",
"lib/insights/correlation-series-builders.ts",
"lib/insights/correlation-channel-series.ts",
"lib/insights/discovery-matrix.ts",
"lib/insights/derived/changepoint.ts",
"lib/labs/lab-trend.ts",
"lib/mental-health/assessment-trend.ts",
"lib/insights/training-load.ts",
] as const;
/** Reachable under `lib/ai/` without being a model: the at-rest cipher. */
const AI_TREE_ALLOWED = new Set(["lib/ai/coach/bytes-codec.ts"]);
/** A provider call, as `ai-egress-capability-guard` defines it. */
const EGRESS_CALL =
/\.generateCompletion(?:Stream)?\s*\(|\brunRawCompletionWithFallback\s*\(|\brunWithFallback\s*\(/;
const VALUE_IMPORT =
/(?:^|\n)\s*(?:import|export)\s+(?!type\b)[^;]*?from\s+["']([^"']+)["']/g;
function resolveImport(fromFile: string, spec: string): string | null {
let base: string;
if (spec.startsWith("@/")) base = join(SRC, spec.slice(2));
else if (spec.startsWith(".")) base = resolve(dirname(fromFile), spec);
else return null;
for (const candidate of [
`${base}.ts`,
`${base}.tsx`,
join(base, "index.ts"),
]) {
if (existsSync(candidate)) return candidate;
}
return null;
}
function closure(entry: string): string[] {
const seen = new Set<string>();
const stack = [join(SRC, entry)];
while (stack.length > 0) {
const file = stack.pop() as string;
if (seen.has(file)) continue;
seen.add(file);
// The generated Prisma client is never read (several MB, no model in it).
if (file.includes(`${SRC}/generated/`)) continue;
const source = readFileSync(file, "utf8");
for (const match of source.matchAll(VALUE_IMPORT)) {
const next = resolveImport(file, match[1]);
if (next) stack.push(next);
}
}
return [...seen].map((f) => relative(SRC, f));
}
describe("the analysis modules compute without a model", () => {
it.each(ANALYSIS_MODULES)("%s reaches no provider", (entry) => {
const reached = closure(entry);
// A walk that found nothing would pass any assertion below.
expect(reached.length).toBeGreaterThan(1);
const ai = reached.filter(
(f) => f.startsWith("lib/ai/") && !AI_TREE_ALLOWED.has(f),
);
expect(ai).toEqual([]);
});
it.each(ANALYSIS_MODULES)("%s makes no provider call", (entry) => {
expect(readFileSync(join(SRC, entry), "utf8")).not.toMatch(EGRESS_CALL);
});
it("would notice a provider import", () => {
// The walk follows the provider module itself into the chain.
expect(closure("lib/ai/provider.ts").length).toBeGreaterThan(1);
expect(
closure("lib/ai/provider.ts").some(
(f) => f === "lib/ai/provider-chain.ts",
),
).toBe(true);
});
});
@@ -96,6 +96,10 @@ describe("T1 — only the assembler fetches discovery channels", () => {
"fetchMoodFactorWindowSeries",
"fetchMeasurementDailySeriesTiered",
"fetchMeasurementWindowSeries",
"fetchNutrientSeries",
"fetchAlcoholSeries",
"fetchTrainingSeries",
"fetchIndoorClimateSeries",
] as const;
/**
@@ -104,6 +104,10 @@ const SQL_READERS: Record<string, Entry> = {
"lib/measurements/daily-series-read.ts": HELPER,
"lib/rollups/measurement-rollups.ts": HELPER,
"lib/rollups/tiered-context.ts": HELPER,
"lib/insights/correlation-channel-series.ts": {
exempt:
"the one SQL mean is the room-climate hour mean, over INDOOR_CO2 / INDOOR_TEMPERATURE / INDOOR_HUMIDITY only; pulse reaches the correlation channels through `dayValueRows` and the rollup compose",
},
};
/** Files that name pulse and hold an in-memory mean or a mean-making fold. */
@@ -58,6 +58,16 @@ const IDENTITY_OWNER: Record<string, string> = {
// about to insert, so a duplicate inside one batch is dropped by the rule
// the list later applies.
"app/api/workouts/batch/route.ts": "app/api/workouts/batch/route.ts",
// The day-total training impulse, the briefing's workout tally and the day
// view's workout section: each counted a session synced by two services
// twice while the list showed it once.
"lib/insights/strain-score.ts": "lib/insights/strain-score.ts",
"lib/insights/feature-blocks.ts": "lib/insights/feature-blocks.ts",
"lib/day/records.ts": "lib/day/records.ts",
// The workout-minutes correlation channel: one session per day per sport,
// as the list shows it.
"lib/insights/correlation-channel-series.ts":
"lib/insights/correlation-channel-series.ts",
};
/**
@@ -16,7 +16,7 @@ vi.mock("@/lib/db", () => {
createMany: vi.fn(),
},
biomarkerMerge: { findFirst: vi.fn(async () => null) },
labResult: { deleteMany: vi.fn() },
labResult: { deleteMany: vi.fn(), findMany: vi.fn(async () => []) },
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
// `$transaction` is set in `beforeEach`: the batch form for the delete, the
@@ -50,7 +50,7 @@ vi.mock("next/headers", () => ({
})),
}));
import { PUT, DELETE } from "../route";
import { GET, PUT, DELETE } from "../route";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -105,6 +105,56 @@ beforeEach(() => {
vi.mocked(prisma.biomarkerMerge.findFirst).mockResolvedValue(null as never);
});
describe("GET /api/biomarkers/[id] — trend (v1.44)", () => {
const YEAR_MS = 365.25 * 86_400_000;
const getReq = () =>
new NextRequest("http://localhost/api/biomarkers/bm-1", { method: "GET" });
it("carries the marker's slope over every stored reading", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(EXISTING as never);
const t0 = Date.now() - 2 * YEAR_MS;
vi.mocked(prisma.labResult.findMany).mockResolvedValue([
{ takenAt: new Date(t0), value: 60 },
{ takenAt: new Date(t0 + YEAR_MS), value: 70 },
{ takenAt: new Date(t0 + 2 * YEAR_MS), value: 80 },
] as never);
const res = await GET(getReq(), params("bm-1"));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.name).toBe("LDL");
expect(body.data.trend).toMatchObject({
status: "ok",
n: 3,
slopePerYear: 10,
projection: "exits",
exitBound: "high",
});
expect(prisma.labResult.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
userId: "user-1",
biomarkerId: "bm-1",
deletedAt: null,
}),
}),
);
});
it("answers insufficient below three readings", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(EXISTING as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
const body = await (await GET(getReq(), params("bm-1"))).json();
expect(body.data.trend).toEqual({
status: "insufficient",
reason: "too_few_values",
n: 0,
spanDays: 0,
});
});
});
describe("DELETE /api/biomarkers/[id]", () => {
it("drops the marker and its readings in one userId-narrowed transaction", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+37 -3
View File
@@ -26,6 +26,10 @@ import { mergedInto } from "@/lib/labs/biomarker-merge";
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
import { matchAnalyteKey } from "@/lib/labs/analyte-match";
import { annotate } from "@/lib/logging/context";
import { computeLabTrend } from "@/lib/labs/lab-trend";
/** Upper bound on readings the trend line reads. */
const LAB_TREND_READ_CAP = 2000;
import { updateBiomarkerSchema } from "@/lib/validations/biomarkers";
import { effectiveBound, isInvertedRange } from "@/lib/validations/labs";
@@ -58,14 +62,44 @@ export const GET = apiHandler(
});
}
const aliases = await aliasNamesByMarker(prisma, user.id, [row]);
const [aliases, readings] = await Promise.all([
aliasNamesByMarker(prisma, user.id, [row]),
// v1.44 — the marker's numeric readings for the trend line. Every
// reading is stored in the marker's unit, so the series needs no
// conversion; the cap is far above any real panel history.
prisma.labResult.findMany({
where: {
userId: user.id,
biomarkerId: id,
deletedAt: null,
value: { not: null },
},
orderBy: { takenAt: "asc" },
take: LAB_TREND_READ_CAP,
select: { takenAt: true, value: true },
}),
]);
const trend = computeLabTrend(
readings.flatMap((r) =>
r.value === null ? [] : [{ takenAt: r.takenAt, value: r.value }],
),
{ low: row.lowerBound, high: row.upperBound },
new Date(),
);
annotate({
action: { name: "labs.biomarker.get" },
meta: { biomarkerId: id },
meta: {
biomarkerId: id,
trend: trend.status,
...(trend.status === "ok" ? { projection: trend.projection } : {}),
},
});
return apiSuccess(serialiseBiomarker(row, aliases.get(row.id) ?? []));
return apiSuccess({
...serialiseBiomarker(row, aliases.get(row.id) ?? []),
trend,
});
},
);
@@ -3,6 +3,10 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
// v1.44 — the extended channel families (intake, training, room climate).
nutrientIntakeDay: { findMany: async () => [] },
workout: { findMany: async () => [] },
strainTrimpCache: { findMany: async () => [] },
auditLog: { create: vi.fn() },
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
user: {
@@ -245,6 +245,11 @@ async function buildCorrelationsResponse(
custom_metric_channels: diagnostics.customMetricChannels,
symptom_event_channels: diagnostics.symptomEventChannels,
custom_metric_days: diagnostics.customMetricDays,
// v1.44 — reach of the extended families.
nutrient_days: diagnostics.nutrientDays,
alcohol_days: diagnostics.alcoholDays,
training_days: diagnostics.trainingDays,
indoor_nights: diagnostics.indoorNights,
},
});
+9 -2
View File
@@ -23,7 +23,8 @@ import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { loadBaselineProfile } from "@/lib/insights/derived";
import { computeCoincidentDeviation } from "@/lib/insights/derived/coincident-deviation";
import { buildChangepointSignals } from "@/lib/insights/derived/changepoint";
import { buildExtendedChangepointSignals } from "@/lib/insights/derived/changepoint";
import { resolveModuleMap } from "@/lib/modules/gate";
import { summariseHealthStatus } from "@/lib/insights/health-status";
export const dynamic = "force-dynamic";
@@ -45,7 +46,13 @@ export const GET = apiHandler(async () => {
tz: user.timezone,
now,
});
const shifts = await buildChangepointSignals(user.id, now);
// v1.44 — the wider scan: every vital with a stable daily level over 180
// days, a metric whose module is off left unread.
const shifts = await buildExtendedChangepointSignals(
user.id,
now,
await resolveModuleMap(user.id),
);
const vitals = coincident.status === "ok" ? coincident.value.vitals : [];
const summary = summariseHealthStatus(vitals, shifts);
@@ -479,4 +479,50 @@ describe("GET /api/mental-health/assessments", () => {
.calls[0][0] as { orderBy: unknown };
expect(findArgs.orderBy).toEqual([{ takenAt: "desc" }, { id: "desc" }]);
});
it("carries each screener's change against its meaningful-change threshold (v1.44)", async () => {
const at = (d: string) => new Date(`${d}T09:00:00.000Z`);
const history = [
{
id: "p2",
instrument: "PHQ9",
totalScore: 8,
takenAt: at("2026-06-01"),
},
{
id: "p1",
instrument: "PHQ9",
totalScore: 15,
takenAt: at("2026-02-01"),
},
];
vi.mocked(prisma.mentalHealthAssessment.findMany)
.mockResolvedValueOnce([] as never)
.mockResolvedValueOnce(history as never);
const res = await (GET as unknown as (r: NextRequest) => Promise<Response>)(
new NextRequest(
new URL("http://localhost/api/mental-health/assessments?limit=1"),
),
);
const body = await res.json();
expect(body.data.trends).toEqual([
expect.objectContaining({
instrument: "PHQ9",
n: 2,
threshold: 5,
changeFromPrevious: -7,
sincePrevious: "improved",
}),
]);
// The trend read selects totals and dates only, never the answers.
const trendArgs = vi.mocked(prisma.mentalHealthAssessment.findMany).mock
.calls[1][0] as { select: Record<string, boolean>; where: unknown };
expect(Object.keys(trendArgs.select).sort()).toEqual([
"id",
"instrument",
"takenAt",
"totalScore",
]);
expect(trendArgs.where).toMatchObject({ deletedAt: null });
});
});
+26 -2
View File
@@ -58,6 +58,16 @@ import type {
MeasurementSource,
} from "@/generated/prisma/client";
import { apiListSuccess } from "@/lib/api/list-cap";
import {
computeAssessmentTrends,
TREND_INSTRUMENTS,
} from "@/lib/mental-health/assessment-trend";
/**
* Upper bound on administrations the trends read; years of weekly check-ins
* across three instruments stay far below it. The cap falls on the oldest.
*/
const TREND_READ_CAP = 1500;
import { listPage } from "@/lib/http/list-query";
function shapeRow(row: {
@@ -149,11 +159,25 @@ export const GET = apiHandler(async (request: NextRequest) => {
},
}),
);
// v1.44 — the change of each screener against its meaningful-change
// threshold, over the whole history rather than the page: totals and dates
// only, never an item answer.
const trendRows = await prisma.mentalHealthAssessment.findMany({
where: {
userId: user.id,
deletedAt: null,
instrument: { in: TREND_INSTRUMENTS },
},
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
take: TREND_READ_CAP,
select: { id: true, instrument: true, totalScore: true, takenAt: true },
});
const trends = computeAssessmentTrends(trendRows);
annotate({
action: { name: "mental-health.list" },
meta: { count: rows.length },
meta: { count: rows.length, trends: trends.length },
});
return apiListSuccess({ assessments: rows.map(shapeRow) }, cap);
return apiListSuccess({ assessments: rows.map(shapeRow), trends }, cap);
});
// `withIdempotency` lets a double-tap / retry re-send the same
@@ -26,6 +26,8 @@ vi.mock("@/lib/db", () => ({
user: {
findUnique: vi.fn(),
},
// v1.44 — the first page carries the training load.
strainTrimpCache: { findMany: vi.fn(async () => []) },
},
}));
@@ -423,3 +425,45 @@ describe("GET /api/workouts — canonical dedup", () => {
);
});
});
describe("GET /api/workouts — training load (v1.44)", () => {
beforeEach(() => {
vi.clearAllMocks();
__resetAllCachesForTests();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
} as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: true,
} as never);
vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never);
});
it("carries fitness, fatigue and form on the first page only", async () => {
const yesterday = new Date(Date.now() - 86_400_000)
.toISOString()
.slice(0, 10);
const rows = Array.from({ length: 50 }, (_, i) => ({
day: new Date(
Date.parse(`${yesterday}T00:00:00Z`) - (49 - i) * 86_400_000,
)
.toISOString()
.slice(0, 10),
dayTrimp: 80,
}));
vi.mocked(prisma.strainTrimpCache.findMany).mockResolvedValue(
rows as never,
);
const first = await (await GET(makeRequest())).json();
expect(first.data.trainingLoad).toMatchObject({
status: "ok",
asOf: yesterday,
daysCovered: 50,
trainingDays: 50,
warmingUp: false,
});
const later = await (await GET(makeRequest("?offset=100"))).json();
expect(later.data.trainingLoad).toBeUndefined();
});
});
@@ -15,6 +15,7 @@ import { WorkoutList } from "@/components/insights/workout-list";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { ManualWorkoutSheet } from "@/components/workouts/manual-workout-sheet";
import { TrainingLoadCard } from "@/components/workouts/training-load-card";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { canLogWorkout } from "@/lib/workouts/manual-entry";
@@ -62,6 +63,7 @@ export default function InsightsWorkoutsPageClient() {
total,
isLoading,
isEmpty,
trainingLoad,
isError,
error,
isFetchNextPageError,
@@ -177,6 +179,7 @@ export default function InsightsWorkoutsPageClient() {
})}
</div>
) : null}
<TrainingLoadCard trainingLoad={trainingLoad} />
{workouts.length > 0 ? (
<>
<p
@@ -155,6 +155,12 @@ export function HealthStatusCard({
before: metricValue(s.metric, s.beforeMean),
after: metricValue(s.metric, s.afterMean),
})}
{s.beforeDays !== undefined && s.afterDays !== undefined
? ` ${t("insights.healthStatus.shiftDays", {
before: s.beforeDays,
after: s.afterDays,
})}`
: null}
</p>
</div>
</div>
@@ -80,8 +80,28 @@ const CHANNEL_LABEL_KEY: Record<string, string> = {
// v1.43 — the Dawarich movement channels.
MOVEMENT_AWAY: "environment.movement.awayFromHome",
MOVEMENT_DISTANCE: "environment.movement.distance",
// v1.44 — the extended channel families.
HRV_RMSSD: "measurements.typeHrvRmssd",
EXERCISE_MINUTES: "measurements.typeExerciseMinutes",
STAND_MINUTES: "measurements.typeStandMinutes",
MINDFUL_MINUTES: "measurements.typeMindfulMinutes",
HYDRATION: "nutrients.names.water",
CAFFEINE: "nutrients.names.caffeine",
ALCOHOL: "insights.mood.discovery.channel.ALCOHOL",
WORKOUT_MINUTES: "insights.mood.discovery.channel.WORKOUT_MINUTES",
TRAINING_LOAD: "insights.mood.discovery.channel.TRAINING_LOAD",
};
/** A micronutrient channel (`NUTRIENT:<code>`) reads under its catalog name. */
const NUTRIENT_PREFIX = "NUTRIENT:";
function channelLabelKey(channelKey: string): string {
if (channelKey.startsWith(NUTRIENT_PREFIX)) {
return `nutrients.names.${channelKey.slice(NUTRIENT_PREFIX.length)}`;
}
return CHANNEL_LABEL_KEY[channelKey] ?? channelKey;
}
/** Keep only the discovered pairs that involve the mood channel. */
export function moodPairsOf(
discovered: DiscoveredCorrelation[],
@@ -131,8 +151,7 @@ export function MoodDiscoveredRelations({
const dynamicLabel = moodIsOutcome
? pair.behaviourLabel
: pair.outcomeLabel;
const factorLabel =
dynamicLabel ?? t(CHANNEL_LABEL_KEY[channelKey] ?? channelKey);
const factorLabel = dynamicLabel ?? t(channelLabelKey(channelKey));
if (
patternId &&
dismissal.isDismissed(patternId, pair.dismissed === true)
@@ -0,0 +1,74 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
import { primeLocales } from "@/__tests__/helpers/prime-locales";
import { LabTrendCard } from "../lab-trend-card";
import type { BiomarkerTrendDto } from "../types";
primeLocales("de");
const OK: BiomarkerTrendDto = {
status: "ok",
n: 4,
firstAt: "2023-01-10",
lastAt: "2026-01-10",
spanDays: 1096,
slopePerYear: 12.5,
slopeCi95: [4.25, 20.75],
pValue: 0.02,
fittedNow: 130,
asOf: "2026-10-21",
projection: "exits",
exitDate: "2027-11-02",
exitBound: "high",
};
function render(
trend: BiomarkerTrendDto | undefined,
locale: "en" | "de" = "en",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<LabTrendCard trend={trend} unit="mg/dL" />
</I18nProvider>,
);
}
describe("<LabTrendCard>", () => {
it("words the server's slope and labels the exit as a projection", () => {
const html = render(OK);
expect(html).toContain("Rising by about 13 mg/dL a year across 4 readings");
expect(html).toContain('data-projection="exits"');
expect(html).toContain("upper end of your reference range");
expect(html).toContain("projection, not a forecast");
expect(html).toContain("4.25 to 20.75 mg/dL");
});
it("speaks German with du", () => {
const html = render(OK, "de");
expect(html).toContain("deines Referenzbereichs");
expect(html).toContain("Fortschreibung, keine Vorhersage");
});
it("draws no projection sentence without a clear trend, and nothing when insufficient", () => {
const html = render({
...OK,
projection: "no_clear_trend",
exitDate: null,
exitBound: null,
});
expect(html).toContain('data-projection="no_clear_trend"');
expect(html).not.toContain("forecast");
expect(
render({
status: "insufficient",
reason: "too_few_values",
n: 2,
spanDays: 40,
}),
).toBe("");
expect(render(undefined)).toBe("");
});
});
@@ -55,6 +55,7 @@ import { importWithRetry } from "@/lib/retry-import";
import { BiomarkerAliases } from "./biomarker-aliases";
import { BiomarkerForm } from "./biomarker-form";
import { LabForm } from "./lab-form";
import { LabTrendCard } from "./lab-trend-card";
import type {
BiomarkerDto,
LabResultDto,
@@ -505,6 +506,10 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
/>
</CardContent>
</Card>
<LabTrendCard
trend={marker?.trend}
unit={marker?.unit ?? latest?.unit ?? ""}
/>
</>
)}
+81
View File
@@ -0,0 +1,81 @@
"use client";
import { TrendingUp } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { BiomarkerTrendDto } from "./types";
/**
* v1.44 — the marker's slope over all its readings, and where that straight
* line would leave the reference band.
*
* Server-authoritative: every number is the route's (`computeLabTrend`); this
* only words it. Renders nothing below three readings over a year. The exit
* sentence names itself a projection, never a forecast.
*/
export function LabTrendCard({
trend,
unit,
}: {
trend: BiomarkerTrendDto | undefined;
unit: string;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
if (!trend || trend.status !== "ok") return null;
const digits = Math.abs(trend.slopePerYear) >= 10 ? 0 : 2;
const per = (v: number) => fmt.number(Math.abs(v), digits);
const direction =
trend.slopePerYear > 0
? "rising"
: trend.slopePerYear < 0
? "falling"
: "flat";
const slopeLine = t(`labs.trend.slope.${direction}`, {
amount: per(trend.slopePerYear),
unit,
n: trend.n,
since: fmt.dateShortSmartCalendar(trend.firstAt),
});
const projectionLine =
trend.projection === "exits" && trend.exitDate
? t(`labs.trend.projection.exits.${trend.exitBound ?? "high"}`, {
date: fmt.dateShortSmartCalendar(trend.exitDate),
})
: trend.projection === "no_band"
? null
: t(`labs.trend.projection.${trend.projection}`);
return (
<Card data-slot="lab-trend">
<CardHeader>
<TileHeader icon={TrendingUp} title={t("labs.trend.title")} />
</CardHeader>
<CardContent className="space-y-1">
<p className="text-sm" data-slot="lab-trend-slope">
{slopeLine}
</p>
{projectionLine ? (
<p
className="text-sm"
data-slot="lab-trend-projection"
data-projection={trend.projection}
>
{projectionLine}
</p>
) : null}
<p className="text-muted-foreground text-xs">
{t("labs.trend.interval", {
lo: fmt.number(trend.slopeCi95[0], 2),
hi: fmt.number(trend.slopeCi95[1], 2),
unit,
})}
</p>
</CardContent>
</Card>
);
}
+32
View File
@@ -108,8 +108,40 @@ export interface BiomarkerDto {
hidden: boolean;
createdAt: string;
updatedAt: string;
/** v1.44 — the detail read's trend line; absent on the list and older servers. */
trend?: BiomarkerTrendDto;
}
/** v1.44 — `GET /api/biomarkers/{id}` `trend` (`computeLabTrend`). */
export type BiomarkerTrendDto =
| {
status: "ok";
n: number;
firstAt: string;
lastAt: string;
spanDays: number;
slopePerYear: number;
slopeCi95: [number, number];
pValue: number;
fittedNow: number;
asOf: string;
projection:
| "exits"
| "beyond_horizon"
| "no_exit"
| "already_outside"
| "no_clear_trend"
| "no_band";
exitDate: string | null;
exitBound: "low" | "high" | null;
}
| {
status: "insufficient";
reason: "too_few_values" | "span_too_short";
n: number;
spanDays: number;
};
export interface BiomarkerListResponse {
biomarkers: BiomarkerDto[];
}
@@ -0,0 +1,65 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
import { primeLocales } from "@/__tests__/helpers/prime-locales";
import { AssessmentTrendNote } from "../assessment-trend-note";
import type { AssessmentTrendDto } from "../types";
primeLocales("de");
const TREND: AssessmentTrendDto = {
instrument: "PHQ9",
n: 3,
threshold: 5,
higherIsBetter: false,
first: { score: 16, takenAt: "2026-01-10T09:00:00.000Z" },
previous: { score: 12, takenAt: "2026-03-15T09:00:00.000Z" },
latest: { score: 9, takenAt: "2026-06-01T09:00:00.000Z" },
changeFromPrevious: -3,
changeFromFirst: -7,
sincePrevious: "within_threshold",
sinceFirst: "improved",
};
const render = (trend: AssessmentTrendDto, locale: "en" | "de" = "en") =>
renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<AssessmentTrendNote trend={trend} />
</I18nProvider>,
);
describe("<AssessmentTrendNote>", () => {
it("states each change and whether it reaches the threshold", () => {
const html = render(TREND);
expect(html).toContain("-3 points");
expect(html).toContain("smaller than the 5-point change");
expect(html).toContain("-7 points");
expect(html).toContain('data-verdict="improved"');
});
it("shows a rise with its sign, in German with du", () => {
const html = render(
{ ...TREND, n: 2, changeFromPrevious: 6, sincePrevious: "worsened" },
"de",
);
expect(html).toContain("Seit deinem vorigen Check-in");
expect(html).toContain("+6 Punkte");
expect(html).not.toContain("ersten");
});
it("is silent with a single check-in", () => {
expect(
render({
...TREND,
n: 1,
previous: null,
changeFromPrevious: null,
changeFromFirst: null,
sincePrevious: null,
sinceFirst: null,
}),
).toBe("");
});
});
@@ -0,0 +1,58 @@
"use client";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { AssessmentTrendDto } from "./types";
/**
* v1.44 — how the latest score compares with the previous check-in (and,
* from the third, with the first), against the change size studies treat as
* meaningful for this instrument. Every number and verdict is the server's;
* the wording stays neutral: a change, its size, and whether it reaches the
* threshold. No band words, no advice.
*/
export function AssessmentTrendNote({ trend }: { trend: AssessmentTrendDto }) {
const { t } = useTranslations();
const { date: formatDate, number } = useFormatters();
if (trend.previous === null || trend.changeFromPrevious === null) return null;
const signed = (delta: number) =>
delta > 0 ? `+${number(delta, 0)}` : number(delta, 0);
const line = (
key: "sincePrevious" | "sinceFirst",
at: string,
delta: number,
verdict: NonNullable<AssessmentTrendDto["sincePrevious"]>,
) =>
`${t(`mentalHealth.trend.${key}`, {
date: formatDate(at),
change: signed(delta),
})} ${t(`mentalHealth.trend.verdict.${verdict}`, {
threshold: trend.threshold,
})}`;
return (
<div className="space-y-1 text-sm" data-slot="assessment-trend">
{trend.sincePrevious ? (
<p data-verdict={trend.sincePrevious}>
{line(
"sincePrevious",
trend.previous.takenAt,
trend.changeFromPrevious,
trend.sincePrevious,
)}
</p>
) : null}
{trend.n >= 3 && trend.sinceFirst && trend.changeFromFirst !== null ? (
<p data-verdict={trend.sinceFirst}>
{line(
"sinceFirst",
trend.first.takenAt,
trend.changeFromFirst,
trend.sinceFirst,
)}
</p>
) : null}
</div>
);
}
@@ -21,16 +21,20 @@ import { relativeCalendarDate } from "@/lib/i18n/relative-time";
import { INSTRUMENTS } from "@/lib/mental-health/instruments";
import { AssessmentHistory } from "./assessment-history";
import type { AssessmentRow, InstrumentId } from "./types";
import type { AssessmentRow, AssessmentTrendDto, InstrumentId } from "./types";
import { AssessmentTrendNote } from "./assessment-trend-note";
export function InstrumentDetail({
instrument,
rows,
trend,
onStart,
}: {
instrument: InstrumentId;
/** ALL assessments; the pinned history filters to this instrument. */
rows: AssessmentRow[];
/** v1.44 — the server's change against the meaningful-change threshold. */
trend?: AssessmentTrendDto;
/** Start a check-in for this instrument (closes the host sheet). */
onStart: () => void;
}) {
@@ -75,6 +79,8 @@ export function InstrumentDetail({
</div>
)}
{trend ? <AssessmentTrendNote trend={trend} /> : null}
{/* The Start action stays reachable here too — the detail is a calm
reading surface, not a dead end. */}
{canManageMind && (
@@ -45,6 +45,7 @@ import { InstrumentCard } from "./instrument-card";
import { InstrumentDetail } from "./instrument-detail";
import type {
AssessmentRow,
AssessmentTrendDto,
CreateResponse,
InstrumentId,
Phase,
@@ -81,9 +82,10 @@ export function MentalWellbeing() {
} = useQuery({
queryKey: queryKeys.mentalHealthAssessments(),
queryFn: () =>
apiGetList<{ assessments: AssessmentRow[] }>(
"/api/mental-health/assessments",
),
apiGetList<{
assessments: AssessmentRow[];
trends?: AssessmentTrendDto[];
}>("/api/mental-health/assessments"),
});
const history = historyPage?.data;
@@ -229,6 +231,9 @@ export function MentalWellbeing() {
<InstrumentDetail
instrument={detailInstrument}
rows={history?.assessments ?? []}
trend={history?.trends?.find(
(trend) => trend.instrument === detailInstrument,
)}
onStart={() => begin(detailInstrument)}
/>
)}
+15
View File
@@ -48,3 +48,18 @@ export interface CreateResponse {
actionThreshold: number;
crisis: CrisisSet | null;
}
/** v1.44 — `GET /api/mental-health/assessments` `trends[]` (`computeAssessmentTrends`). */
export interface AssessmentTrendDto {
instrument: "PHQ9" | "GAD7" | "WHO5";
n: number;
threshold: number;
higherIsBetter: boolean;
first: { score: number; takenAt: string };
previous: { score: number; takenAt: string } | null;
latest: { score: number; takenAt: string };
changeFromPrevious: number | null;
changeFromFirst: number | null;
sincePrevious: "improved" | "worsened" | "within_threshold" | null;
sinceFirst: "improved" | "worsened" | "within_threshold" | null;
}
@@ -0,0 +1,59 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
import { primeLocales } from "@/__tests__/helpers/prime-locales";
import type { TrainingLoad } from "@/lib/insights/training-load";
import { TrainingLoadCard } from "../training-load-card";
primeLocales("de");
const OK: TrainingLoad = {
status: "ok",
asOf: "2026-10-20",
ctl: 54.2,
atl: 61.8,
tsb: -7.6,
daysCovered: 30,
trainingDays: 18,
warmingUp: true,
series: [],
};
const render = (load: TrainingLoad | undefined, locale: "en" | "de" = "en") =>
renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<TrainingLoadCard trainingLoad={load} />
</I18nProvider>,
);
describe("<TrainingLoadCard>", () => {
it("shows the server's fitness, fatigue and form", () => {
const html = render(OK);
expect(html).toContain("Training load");
expect(html).toMatch(/training-load-fitness[^]*?>54</);
expect(html).toMatch(/training-load-fatigue[^]*?>62</);
expect(html).toContain("-8");
expect(html).toContain("Based on 30 days so far");
});
it("signs a positive form and drops the warm-up note once covered", () => {
const html = render({ ...OK, tsb: 4.4, warmingUp: false }, "de");
expect(html).toContain("+");
expect(html).toContain("Trainingslast");
expect(html).not.toContain("bisher");
});
it("renders nothing while insufficient", () => {
expect(
render({
status: "insufficient",
reason: "too_little_history",
daysCovered: 12,
trainingDays: 2,
}),
).toBe("");
expect(render(undefined)).toBe("");
});
});
@@ -0,0 +1,60 @@
"use client";
import { Gauge } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { TrainingLoad } from "@/lib/insights/training-load";
/**
* v1.44 — fitness, fatigue and form from the stored training impulse, the
* server's figures as given. Renders nothing until the model has 28 days and
* three training days to stand on.
*/
export function TrainingLoadCard({
trainingLoad,
}: {
trainingLoad: TrainingLoad | undefined;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
if (!trainingLoad || trainingLoad.status !== "ok") return null;
const stats = [
{ key: "fitness", value: trainingLoad.ctl },
{ key: "fatigue", value: trainingLoad.atl },
{ key: "form", value: trainingLoad.tsb },
] as const;
return (
<Card data-slot="training-load">
<CardHeader>
<TileHeader icon={Gauge} title={t("insights.workouts.load.title")} />
</CardHeader>
<CardContent className="space-y-2">
<dl className="grid grid-cols-3 gap-3">
{stats.map((stat) => (
<div key={stat.key} data-slot={`training-load-${stat.key}`}>
<dt className="text-muted-foreground text-xs">
{t(`insights.workouts.load.${stat.key}`)}
</dt>
<dd className="text-foreground text-lg font-semibold tabular-nums">
{stat.key === "form" && stat.value > 0 ? "+" : ""}
{fmt.number(stat.value, 0)}
</dd>
</div>
))}
</dl>
<p className="text-sm">{t("insights.workouts.load.explainer")}</p>
{trainingLoad.warmingUp ? (
<p className="text-muted-foreground text-xs">
{t("insights.workouts.load.warmingUp", {
days: trainingLoad.daysCovered,
})}
</p>
) : null}
</CardContent>
</Card>
);
}
+7
View File
@@ -1,5 +1,6 @@
"use client";
import type { TrainingLoad } from "@/lib/insights/training-load";
import { useMemo } from "react";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
@@ -55,6 +56,8 @@ export interface WorkoutListMeta {
export interface WorkoutListPayload {
workouts: WorkoutListEntry[];
meta: WorkoutListMeta;
/** v1.44 — first page only; see `GET /api/workouts`. */
trainingLoad?: TrainingLoad;
}
export interface UseWorkoutsOptions {
@@ -179,6 +182,8 @@ export interface UseInfiniteWorkoutsResult {
total: number;
isLoading: boolean;
isEmpty: boolean;
/** v1.44 — fitness, fatigue and form, from the first page. */
trainingLoad: TrainingLoad | undefined;
error: Error | null;
/** True when the first page failed and there is no history to preserve. */
isError: boolean;
@@ -218,6 +223,7 @@ export function useInfiniteWorkouts(
[pages],
);
const total = query.data?.pages[0]?.meta.total ?? 0;
const trainingLoad = query.data?.pages[0]?.trainingLoad;
const isEmpty =
Boolean(isAuthenticated) &&
query.data !== undefined &&
@@ -228,6 +234,7 @@ export function useInfiniteWorkouts(
total,
isLoading: query.isLoading,
isEmpty,
trainingLoad,
error: query.error as Error | null,
isError: query.isError && query.data === undefined,
isFetchNextPageError: query.isFetchNextPageError,
@@ -23,6 +23,11 @@ vi.mock("@/lib/modules/gate", () => ({
}));
vi.mock("@/lib/db", () => ({
prisma: {
// v1.44 — the extended channel families (intake, training, room climate).
nutrientIntakeDay: { findMany: async () => [] },
workout: { findMany: async () => [] },
strainTrimpCache: { findMany: async () => [] },
$queryRaw: async () => [],
measurement: { findMany: (a: unknown) => measurementFindMany(a) },
moodEntry: { findMany: (a: unknown) => moodFindMany(a) },
user: { findUnique: (a: unknown) => userFindUnique(a) },
+27 -13
View File
@@ -50,6 +50,10 @@ import { wallClockInTz } from "@/lib/tz/wall-clock";
import {
discoverCorrelations,
discoverEmergingCorrelations,
withoutModelFencedPairs,
CAFFEINE_CHANNEL_KEY,
HYDRATION_CHANNEL_KEY,
NUTRIENT_CHANNEL_PREFIX,
discoverLabOutcomeCorrelations,
EARLY_WINDOW_DAYS,
MEDICATION_COMPLIANCE_CHANNEL_KEY,
@@ -158,6 +162,8 @@ function tzDayKey(at: Date, tz: string): string {
const CHANNEL_LABELS: Record<string, string> = {
[MEDICATION_COMPLIANCE_CHANNEL_KEY]: "medication adherence",
[SYMPTOM_SEVERITY_CHANNEL_KEY]: "symptom severity",
[HYDRATION_CHANNEL_KEY]: "water intake",
[CAFFEINE_CHANNEL_KEY]: "caffeine intake",
...Object.fromEntries(
ENVIRONMENT_FIELDS.map((field) => [field.key, field.narrationLabel]),
),
@@ -172,6 +178,9 @@ const CHANNEL_LABELS: Record<string, string> = {
*/
function humanise(key: string, label?: string): string {
if (label && label.trim().length > 0) return label.trim().toLowerCase();
if (key.startsWith(NUTRIENT_CHANNEL_PREFIX)) {
return `${key.slice(NUTRIENT_CHANNEL_PREFIX.length).replace(/_/g, " ")} intake`;
}
return CHANNEL_LABELS[key] ?? key.replace(/_/g, " ").toLowerCase();
}
@@ -281,7 +290,12 @@ export async function readCoachCorrelations(
});
const discovery = discoverCorrelations(series, { locale });
const drivers: CoachCorrelationDriver[] = discovery.discovered.map((d) => ({
// The Coach is a model: pairs on a fenced channel never reach it. The
// scan above is the shared one, so the remaining pairs' statistics are the
// ones the correlations page shows.
const drivers: CoachCorrelationDriver[] = withoutModelFencedPairs(
discovery.discovered,
).map((d) => ({
behaviour: humanise(d.behaviour, d.behaviourLabel),
outcome: humanise(d.outcome, d.outcomeLabel),
direction: d.r >= 0 ? "higher" : "lower",
@@ -303,18 +317,18 @@ export async function readCoachCorrelations(
recentFromDayKey,
locale,
});
const emerging: CoachEmergingDriver[] = emergingResult.emerging.map(
(d) => ({
behaviour: humanise(d.behaviour, d.behaviourLabel),
outcome: humanise(d.outcome, d.outcomeLabel),
direction: d.r >= 0 ? "higher" : "lower",
lagDays: d.lagDays,
n: d.n,
r: Math.round(d.r * 100) / 100,
note: d.interpretation,
provisional: true,
}),
);
const emerging: CoachEmergingDriver[] = withoutModelFencedPairs(
emergingResult.emerging,
).map((d) => ({
behaviour: humanise(d.behaviour, d.behaviourLabel),
outcome: humanise(d.outcome, d.outcomeLabel),
direction: d.r >= 0 ? "higher" : "lower",
lagDays: d.lagDays,
n: d.n,
r: Math.round(d.r * 100) / 100,
note: d.interpretation,
provisional: true,
}));
// v1.22 — labs ↔ outcome pass (point-vs-window over sparse draws).
const labResult = discoverLabOutcomeCorrelations(labDraws, series, {
+27 -8
View File
@@ -42,6 +42,7 @@ import { effectiveMoodTz } from "@/lib/mood/date-key";
import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format";
import { visitPath } from "@/lib/encounters/visit-links";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
/** Everything a section reader needs about the day. */
export interface DayFrame {
@@ -904,14 +905,32 @@ async function assessmentsPart(frame: DayFrame): Promise<SectionPart> {
/* ─── workouts, cycle ─────────────────────────────────────────────────────── */
async function workoutsPart(frame: DayFrame): Promise<SectionPart> {
const rows = await prisma.workout.findMany({
where: {
userId: frame.userId,
startedAt: { gte: frame.dayStart, lt: frame.dayEnd },
},
select: { id: true, sportType: true, startedAt: true, durationSec: true },
orderBy: { startedAt: "asc" },
});
const [stored, account] = await Promise.all([
prisma.workout.findMany({
where: {
userId: frame.userId,
startedAt: { gte: frame.dayStart, lt: frame.dayEnd },
},
select: {
id: true,
source: true,
sportType: true,
startedAt: true,
durationSec: true,
},
orderBy: [{ startedAt: "asc" }, { id: "asc" }],
}),
prisma.user.findUnique({
where: { id: frame.userId },
select: { sourcePriorityJson: true },
}),
]);
// One session synced by two services is one workout on the day, as in the
// workout list it links to.
const rows = pickCanonicalWorkoutRows(
stored,
account?.sourcePriorityJson ?? null,
);
return {
running: [],
events: rows.map((row) =>
@@ -0,0 +1,284 @@
/**
* v1.44 — the extended correlation channel families: the pure series shapers
* (intake, alcohol days, workout minutes, training load, overnight room
* climate) and the engine's per-channel `targets` / `phrasing`, plus the fence
* that keeps the sensitive channels away from every model-bound surface.
*/
import { describe, expect, it } from "vitest";
import {
ALCOHOL_CHANNEL_KEY,
CAFFEINE_CHANNEL_KEY,
discoverCorrelations,
HYDRATION_CHANNEL_KEY,
INDOOR_CLIMATE_TARGETS,
metricFamily,
MODEL_FENCED_CHANNELS,
withoutModelFencedPairs,
type DailySeriesPoint,
type NamedSeries,
} from "../correlation-discovery";
import {
buildAlcoholDailySeries,
buildNutrientDailySeries,
buildOvernightClimateSeries,
buildTrainingLoadSeries,
buildWorkoutMinutesSeries,
MAX_MICRONUTRIENT_CHANNELS,
MIN_ALCOHOL_DAYS,
MIN_OVERNIGHT_HOURS,
} from "../correlation-series-builders";
import { shiftDateKey } from "@/lib/tz/format";
const DAY0 = "2026-01-01";
const dayN = (n: number) => shiftDateKey(DAY0, n);
function daily(values: number[], from = 0): DailySeriesPoint[] {
return values.map((value, i) => ({ day: dayN(from + i), value }));
}
describe("buildNutrientDailySeries", () => {
it("sums sources per day and maps water and caffeine to their channels", () => {
const out = buildNutrientDailySeries(
[
{ day: dayN(0), nutrient: "water", amount: 1200 },
{ day: dayN(0), nutrient: "water", amount: 300 },
{ day: dayN(1), nutrient: "caffeine", amount: 95 },
],
{ minDays: 20 },
);
expect(out.map((s) => s.key)).toEqual([
HYDRATION_CHANNEL_KEY,
CAFFEINE_CHANNEL_KEY,
]);
expect(out[0].points).toEqual([{ day: dayN(0), value: 1500 }]);
expect(out[1].points).toEqual([{ day: dayN(1), value: 95 }]);
});
it("admits the most-logged varying micronutrients, capped, never a constant", () => {
const rows = [];
// Six varying micronutrients with 30..25 days, one constant with 40.
const codes = [
"zinc",
"iron",
"magnesium",
"vitamin_d",
"selenium",
"iodine",
];
codes.forEach((code, c) => {
for (let d = 0; d < 30 - c; d += 1) {
rows.push({ day: dayN(d), nutrient: code, amount: 10 + (d % 3) });
}
});
for (let d = 0; d < 40; d += 1) {
rows.push({ day: dayN(d), nutrient: "vitamin_c", amount: 500 });
}
// One varying code below the day floor.
for (let d = 0; d < 10; d += 1) {
rows.push({ day: dayN(d), nutrient: "copper", amount: d });
}
const keys = buildNutrientDailySeries(rows, { minDays: 20 })
.map((s) => s.key)
.slice(2);
expect(keys).toHaveLength(MAX_MICRONUTRIENT_CHANNELS);
expect(keys).toEqual([
"NUTRIENT:zinc",
"NUTRIENT:iron",
"NUTRIENT:magnesium",
"NUTRIENT:vitamin_d",
]);
});
});
describe("buildAlcoholDailySeries", () => {
const at = (n: number, hour = 20) =>
new Date(`${dayN(n)}T${String(hour).padStart(2, "0")}:00:00.000Z`);
it("marks a day 1 when any entry is tagged, 0 when entries are untagged", () => {
const entries = [];
for (let d = 0; d < 12; d += 1) {
entries.push({ at: at(d, 9), tagged: false });
entries.push({ at: at(d, 21), tagged: d % 2 === 0 });
}
const out = buildAlcoholDailySeries(entries, "UTC");
expect(out.key).toBe(ALCOHOL_CHANNEL_KEY);
expect(out.points).toHaveLength(12);
expect(out.points.filter((p) => p.value === 1)).toHaveLength(6);
expect(out.points[0]).toEqual({ day: dayN(0), value: 1 });
expect(out.points[1]).toEqual({ day: dayN(1), value: 0 });
});
it("is empty below the tagged-day floor or with nothing to compare against", () => {
const few = Array.from({ length: 20 }, (_, d) => ({
at: at(d),
tagged: d < MIN_ALCOHOL_DAYS - 1,
}));
expect(buildAlcoholDailySeries(few, "UTC").points).toEqual([]);
const all = Array.from({ length: 20 }, (_, d) => ({
at: at(d),
tagged: true,
}));
expect(buildAlcoholDailySeries(all, "UTC").points).toEqual([]);
});
});
describe("buildWorkoutMinutesSeries / buildTrainingLoadSeries", () => {
it("sums a day's sessions and zero-fills only between the first and last", () => {
const out = buildWorkoutMinutesSeries(
[
{ startedAt: new Date(`${dayN(0)}T07:00:00Z`), durationSec: 1800 },
{ startedAt: new Date(`${dayN(0)}T18:00:00Z`), durationSec: 900 },
{ startedAt: new Date(`${dayN(3)}T07:00:00Z`), durationSec: 3600 },
],
"UTC",
);
expect(out.points).toEqual([
{ day: dayN(0), value: 45 },
{ day: dayN(1), value: 0 },
{ day: dayN(2), value: 0 },
{ day: dayN(3), value: 60 },
]);
});
it("keys a session on the person's day, not UTC's", () => {
const out = buildWorkoutMinutesSeries(
[{ startedAt: new Date(`${dayN(0)}T23:30:00Z`), durationSec: 600 }],
"Europe/Berlin",
);
expect(out.points).toEqual([{ day: dayN(1), value: 10 }]);
});
it("fills a missing strain day with zero impulse", () => {
const out = buildTrainingLoadSeries([
{ day: dayN(0), dayTrimp: 80 },
{ day: dayN(2), dayTrimp: 40 },
]);
expect(out.points).toEqual([
{ day: dayN(0), value: 80 },
{ day: dayN(1), value: 0 },
{ day: dayN(2), value: 40 },
]);
});
});
describe("buildOvernightClimateSeries", () => {
it("keys 21:00–07:00 on the waking day, averages hours, drops thin nights", () => {
const rows = [];
// Night into day 1: 21–23 on day 0 (600 ppm), 0–4 on day 1 (900 ppm).
for (const h of [21, 22, 23]) {
rows.push({ type: "INDOOR_CO2", day: dayN(0), hour: h, mean: 600 });
}
for (const h of [0, 1, 2, 3, 4]) {
rows.push({ type: "INDOOR_CO2", day: dayN(1), hour: h, mean: 900 });
}
// Daytime hours never count.
rows.push({ type: "INDOOR_CO2", day: dayN(1), hour: 12, mean: 5000 });
// A thin night into day 2.
for (let h = 0; h < MIN_OVERNIGHT_HOURS - 1; h += 1) {
rows.push({ type: "INDOOR_CO2", day: dayN(2), hour: h, mean: 700 });
}
const [co2, temp] = buildOvernightClimateSeries(
rows,
["INDOOR_CO2", "INDOOR_TEMPERATURE"],
INDOOR_CLIMATE_TARGETS,
);
// (3·600 + 5·900) / 8 = 787.5
expect(co2.points).toEqual([{ day: dayN(1), value: 787.5 }]);
expect(co2.lagDays).toBe(0);
expect(co2.phrasing).toBe("overnight");
expect(co2.targets).toEqual(["SLEEP_DURATION"]);
expect(temp.points).toEqual([]);
});
});
describe("discoverCorrelations — targets and phrasing", () => {
// 60 days of a behaviour alternating in blocks, an outcome that is an exact
// linear function of the previous day's behaviour: r = −1 after any
// detrending, n = 59 lag-joined days.
const x = Array.from({ length: 60 }, (_, d) => (d % 4 < 2 ? 100 : 300));
const sleepNextDay = [420, ...x.slice(0, 59).map((v) => 480 - 0.5 * v)];
it("finds caffeine → next-day sleep with the exact n and r", () => {
const series: NamedSeries[] = [
{ key: CAFFEINE_CHANNEL_KEY, role: "behaviour", points: daily(x) },
{ key: "SLEEP_DURATION", role: "outcome", points: daily(sleepNextDay) },
];
const result = discoverCorrelations(series, { locale: "en" });
expect(result.pairsTested).toBe(1);
const pair = result.discovered[0];
expect(pair.behaviour).toBe(CAFFEINE_CHANNEL_KEY);
expect(pair.n).toBe(59);
expect(pair.r).toBeCloseTo(-1, 9);
expect(pair.interpretation).toContain("caffeine intake");
});
it("tests a targeted channel against its targets only", () => {
const series: NamedSeries[] = [
{
key: "INDOOR_CO2",
role: "behaviour",
lagDays: 0,
phrasing: "overnight",
targets: INDOOR_CLIMATE_TARGETS,
points: daily(x),
},
{
key: "SLEEP_DURATION",
role: "outcome",
points: daily(x.map((v) => 480 - 0.5 * v)),
},
{ key: "MOOD", role: "outcome", points: daily(x.map((v) => v / 100)) },
{
key: "RESTING_HEART_RATE",
role: "outcome",
points: daily(x.map((v) => 50 + v / 50)),
},
];
const result = discoverCorrelations(series, { locale: "en" });
expect(result.pairsTested).toBe(1);
expect(result.discovered).toHaveLength(1);
const pair = result.discovered[0];
expect(pair.outcome).toBe("SLEEP_DURATION");
expect(pair.lagDays).toBe(0);
expect(pair.n).toBe(60);
expect(pair.r).toBeCloseTo(-1, 9);
expect(pair.interpretation).toContain("overnight");
expect(pair.interpretation).toContain("that night");
});
it("names a micronutrient by its catalog name in the reader's language", () => {
const series: NamedSeries[] = [
{ key: "NUTRIENT:magnesium", role: "behaviour", points: daily(x) },
{ key: "SLEEP_DURATION", role: "outcome", points: daily(sleepNextDay) },
];
const [pair] = discoverCorrelations(series, { locale: "de" }).discovered;
expect(pair.interpretation).toContain("die Zufuhr von Magnesium");
});
});
describe("families and the model fence", () => {
it("puts State of Mind in mood's family and the new families in their own", () => {
expect(metricFamily("STATE_OF_MIND_VALENCE")).toBe("MOOD");
expect(metricFamily("INDOOR_CO2")).toBe("INDOOR");
expect(metricFamily("TRAINING_LOAD")).toBe("TRAINING");
expect(metricFamily("WORKOUT_MINUTES")).toBe("TRAINING");
expect(metricFamily("NUTRIENT:zinc")).toBe("NUTRIENTS");
expect(metricFamily(CAFFEINE_CHANNEL_KEY)).toBe("NUTRIENTS");
});
it("drops every pair that touches a fenced channel", () => {
const pairs = [
{ behaviour: ALCOHOL_CHANNEL_KEY, outcome: "SLEEP_DURATION" },
{ behaviour: "MINDFUL_MINUTES", outcome: "MOOD" },
{ behaviour: "ACTIVITY_STEPS", outcome: "STATE_OF_MIND_VALENCE" },
{ behaviour: CAFFEINE_CHANNEL_KEY, outcome: "SLEEP_DURATION" },
];
expect(withoutModelFencedPairs(pairs)).toEqual([pairs[3]]);
expect([...MODEL_FENCED_CHANNELS].sort()).toEqual([
"ALCOHOL",
"MINDFUL_MINUTES",
"STATE_OF_MIND_VALENCE",
]);
});
});
@@ -48,6 +48,29 @@ vi.mock("@/lib/insights/correlation-channel-series", () => ({
fetchSymptomEventSeries: vi.fn(async () => [
{ key: "SYMPTOM:def1", label: "Aura", role: "outcome", points: pts },
]),
fetchNutrientSeries: vi.fn(async () => [
{ key: "HYDRATION", role: "behaviour", points: pts },
{ key: "CAFFEINE", role: "behaviour", points: pts },
{ key: "NUTRIENT:zinc", role: "behaviour", points: pts },
]),
fetchAlcoholSeries: vi.fn(async () => ({
key: "ALCOHOL",
role: "behaviour",
points: pts,
})),
fetchTrainingSeries: vi.fn(async () => [
{ key: "WORKOUT_MINUTES", role: "behaviour", points: pts },
{ key: "TRAINING_LOAD", role: "behaviour", points: pts },
]),
fetchIndoorClimateSeries: vi.fn(async () => [
{
key: "INDOOR_CO2",
role: "behaviour",
lagDays: 0,
targets: ["SLEEP_DURATION"],
points: pts,
},
]),
}));
vi.mock("@/lib/rollups/measurement-read", () => ({
loadUserSourcePriority: vi.fn(),
@@ -58,8 +81,12 @@ import {
maskSeriesByModules,
} from "@/lib/insights/discovery-matrix";
import {
fetchAlcoholSeries,
fetchEnvironmentSeries,
fetchIndoorClimateSeries,
fetchMovementSeries,
fetchNutrientSeries,
fetchTrainingSeries,
} from "@/lib/insights/correlation-channel-series";
const OPTS = {
@@ -177,6 +204,77 @@ describe("assembleDiscoveryMatrix — switched-off modules", () => {
});
});
describe("assembleDiscoveryMatrix — the extended families (v1.44)", () => {
const EXTENDED = [
"HYDRATION",
"CAFFEINE",
"NUTRIENT:zinc",
"ALCOHOL",
"WORKOUT_MINUTES",
"TRAINING_LOAD",
"EXERCISE_MINUTES",
"STAND_MINUTES",
"MINDFUL_MINUTES",
"STATE_OF_MIND_VALENCE",
"INDOOR_CO2",
];
it("folds every extended family in after the earlier ones", async () => {
const { series, byMetric } = await assembleDiscoveryMatrix("u1", OPTS);
const keys = series.map((s) => s.key);
for (const key of EXTENDED) expect(keys, key).toContain(key);
// Earlier families keep their place: the first extended key comes after
// the last person-defined symptom.
expect(keys.indexOf("EXERCISE_MINUTES")).toBeGreaterThan(
keys.indexOf("SYMPTOM:def1"),
);
expect(series.find((s) => s.key === "STATE_OF_MIND_VALENCE")?.role).toBe(
"outcome",
);
// The extended types feed the scan only, never the narrative's metrics.
for (const key of ["EXERCISE_MINUTES", "STATE_OF_MIND_VALENCE"]) {
expect(byMetric.has(key), key).toBe(false);
}
});
it("reads and keeps each family only while its module is on", async () => {
const reads = [
fetchNutrientSeries,
fetchAlcoholSeries,
fetchTrainingSeries,
fetchIndoorClimateSeries,
].map((fn) => vi.mocked(fn));
for (const read of reads) read.mockClear();
const { series } = await assembleDiscoveryMatrix("u1", {
...OPTS,
modules: {
nutrients: false,
mood: false,
workouts: false,
environment: false,
},
});
for (const read of reads) expect(read).not.toHaveBeenCalled();
const keys = series.map((s) => s.key);
for (const gone of [
"HYDRATION",
"CAFFEINE",
"NUTRIENT:zinc",
"ALCOHOL",
"WORKOUT_MINUTES",
"TRAINING_LOAD",
"MINDFUL_MINUTES",
"STATE_OF_MIND_VALENCE",
"INDOOR_CO2",
]) {
expect(keys, gone).not.toContain(gone);
}
// Exercise and stand minutes are ambient movement and carry no owner.
expect(keys).toContain("EXERCISE_MINUTES");
expect(keys).toContain("STAND_MINUTES");
});
});
describe("maskSeriesByModules", () => {
it("keeps the fold order of what remains", () => {
const series = ["WEIGHT", "MOOD", "PULSE", "FACTOR:x"].map((key) => ({
@@ -557,6 +557,30 @@ describe("extractFeatures — v1.22 workouts block", () => {
expect(f.workouts?.last7.totalDistanceKm).toBe(5);
expect(f.workouts?.latest?.sportType).toBe("running");
});
it("counts a session synced by two services once", async () => {
const startedAt = new Date(Date.now() - 1 * DAY_MS);
prismaMock.workout.findMany.mockResolvedValue([
{
source: "APPLE_HEALTH",
sportType: "running",
startedAt,
durationSec: 1800,
totalDistanceM: 5000,
},
{
source: "STRAVA",
sportType: "running",
startedAt: new Date(startedAt.getTime() + 30_000),
durationSec: 1795,
totalDistanceM: 5010,
},
]);
const f = await extractFeatures("user-1", false);
expect(f.workouts?.last7.count).toBe(1);
expect(f.workouts?.last7.totalDurationMin).toBe(30);
expect(f.workouts?.last7.totalDistanceKm).toBe(5);
});
});
describe("extractFeatures — v1.22 integration blocks are briefing-scoped", () => {
@@ -2,6 +2,11 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
// v1.44 — the extended channel families (intake, training, room climate).
nutrientIntakeDay: { findMany: async () => [] },
workout: { findMany: async () => [] },
strainTrimpCache: { findMany: async () => [] },
$queryRaw: async () => [],
user: { findUnique: vi.fn() },
measurement: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
@@ -32,6 +32,7 @@ import {
percentile,
resolvePersonalReference,
persistStrainScore,
canonicalWorkoutTrimps,
STRAIN_SCORE_EXTERNAL_ID_PREFIX,
STRAIN_TRIMP_REFERENCE,
STRAIN_MIN_TRAINING_DAYS,
@@ -152,7 +153,14 @@ describe("saturateToScore", () => {
function makePrisma(opts: {
energy?: number[];
workouts?: Array<{ id: string; samples: { samples: unknown } | null }>;
workouts?: Array<{
id: string;
samples: { samples: unknown } | null;
source?: string;
sportType?: string;
startedAt?: Date;
durationSec?: number;
}>;
rhr?: number | null;
gender?: string;
dateOfBirth?: Date | null;
@@ -172,7 +180,17 @@ function makePrisma(opts: {
const measurementFindFirst = vi
.fn()
.mockResolvedValue(opts.rhr == null ? null : { value: opts.rhr });
const workoutFindMany = vi.fn().mockResolvedValue(opts.workouts ?? []);
// Distinct sessions by default: each fixture workout starts an hour after
// the previous one, so the cross-source picker keeps every row.
const workoutFindMany = vi.fn().mockResolvedValue(
(opts.workouts ?? []).map((w, i) => ({
source: "APPLE_HEALTH",
sportType: "RUNNING",
startedAt: new Date(Date.UTC(2026, 5, 1, 6 + i)),
durationSec: 1800,
...w,
})),
);
const upsert = vi.fn().mockResolvedValue({});
const findUnique = vi.fn().mockResolvedValue({
dateOfBirth:
@@ -519,3 +537,77 @@ describe("persistStrainScore — personal anchor + cache", () => {
expect(cacheArg.create.refPersonal).toBeLessThan(80);
});
});
describe("canonicalWorkoutTrimps — one impulse per session", () => {
const start = new Date("2026-06-01T07:00:00.000Z");
const row = (
id: string,
source: string,
trimp: number,
offsetMin = 0,
sportType = "RUNNING",
) => ({
id,
source: source as "APPLE_HEALTH",
sportType,
startedAt: new Date(start.getTime() + offsetMin * 60_000),
durationSec: 1800,
trimp,
});
it("counts a session synced by two services once", () => {
const rows = [row("a", "APPLE_HEALTH", 40), row("s", "STRAVA", 38, 1)];
expect(canonicalWorkoutTrimps(rows, null, (r) => r.trimp)).toEqual([40]);
});
it("takes the dropped twin's impulse when the picked row has no series", () => {
// Apple ranks first on the default ladder; its copy carries no HR here.
const rows = [row("a", "APPLE_HEALTH", 0), row("w", "WHOOP", 55, 2)];
expect(canonicalWorkoutTrimps(rows, null, (r) => r.trimp)).toEqual([55]);
});
it("keeps two distinct sessions apart", () => {
const rows = [
row("a", "APPLE_HEALTH", 40),
row("b", "APPLE_HEALTH", 20, 120),
row("c", "APPLE_HEALTH", 15, 1, "CYCLING"),
];
expect(
canonicalWorkoutTrimps(rows, null, (r) => r.trimp).sort((x, y) => x - y),
).toEqual([15, 20, 40]);
});
it("is empty when nothing carries an impulse", () => {
expect(
canonicalWorkoutTrimps([row("a", "APPLE_HEALTH", 0)], null, () => 0),
).toEqual([]);
});
});
describe("persistStrainScore — duplicate sessions", () => {
it("scores a session stored twice the same as the session stored once", async () => {
const series = {
samples: [
{ t: "2026-06-01T08:00:00.000Z", hr: 150 },
{ t: "2026-06-01T08:30:00.000Z", hr: 155 },
{ t: "2026-06-01T09:00:00.000Z", hr: 150 },
],
};
const startedAt = new Date("2026-06-01T08:00:00.000Z");
const once = makePrisma({
rhr: 50,
workouts: [{ id: "w1", samples: series, startedAt }],
});
const twice = makePrisma({
rhr: 50,
workouts: [
{ id: "w1", samples: series, startedAt },
{ id: "w2", samples: series, startedAt, source: "STRAVA" },
],
});
const a = await persistStrainScore(once.prisma, "user-1", NOW);
const b = await persistStrainScore(twice.prisma, "user-1", NOW);
expect(a.score).toBeGreaterThan(0);
expect(b.score).toBe(a.score);
});
});
@@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import { shiftDateKey } from "@/lib/tz/format";
import { computeTrainingLoad } from "../training-load";
const D0 = "2026-01-01";
const day = (n: number) => shiftDateKey(D0, n);
describe("computeTrainingLoad", () => {
it("matches the closed form for a constant daily impulse", () => {
// 100 every day for 60 days: CTL = 100·(1 − (41/42)^60),
// ATL = 100·(1 − (6/7)^60).
const rows = Array.from({ length: 60 }, (_, i) => ({
day: day(i),
dayTrimp: 100,
}));
const load = computeTrainingLoad(rows, day(59));
if (load.status !== "ok") throw new Error("expected ok");
const ctl = 100 * (1 - (41 / 42) ** 60);
const atl = 100 * (1 - (6 / 7) ** 60);
expect(load.ctl).toBe(Math.round(ctl * 10) / 10);
expect(load.atl).toBe(Math.round(atl * 10) / 10);
expect(load.tsb).toBe(Math.round((ctl - atl) * 10) / 10);
expect(load.daysCovered).toBe(60);
expect(load.trainingDays).toBe(60);
expect(load.warmingUp).toBe(false);
expect(load.series).toHaveLength(60);
// Form on day 0 is taken going into the day: both averages are still 0.
expect(load.series[0]).toEqual({
day: day(0),
ctl: 2.4,
atl: 14.3,
tsb: 0,
});
});
it("counts a day without a stored row as a rest day", () => {
// Train on day 0 only, then 29 empty days up to day 29.
const load = computeTrainingLoad(
[
{ day: day(0), dayTrimp: 84 },
{ day: day(1), dayTrimp: 42 },
{ day: day(2), dayTrimp: 42 },
],
day(29),
);
if (load.status !== "ok") throw new Error("expected ok");
let ctl = 0;
let atl = 0;
for (let i = 0; i < 30; i += 1) {
const l = i === 0 ? 84 : i < 3 ? 42 : 0;
ctl += (l - ctl) / 42;
atl += (l - atl) / 7;
}
expect(load.ctl).toBe(Math.round(ctl * 10) / 10);
expect(load.atl).toBe(Math.round(atl * 10) / 10);
expect(load.daysCovered).toBe(30);
expect(load.warmingUp).toBe(true);
});
it("answers insufficient without impulse or with too little history", () => {
expect(computeTrainingLoad([], day(10))).toMatchObject({
status: "insufficient",
reason: "no_impulse_history",
});
expect(
computeTrainingLoad([{ day: day(0), dayTrimp: 0 }], day(40)),
).toMatchObject({ status: "insufficient", reason: "no_impulse_history" });
expect(
computeTrainingLoad(
Array.from({ length: 10 }, (_, i) => ({ day: day(i), dayTrimp: 50 })),
day(9),
),
).toMatchObject({
status: "insufficient",
reason: "too_little_history",
daysCovered: 10,
});
});
it("ignores rows after the day it describes", () => {
const rows = Array.from({ length: 40 }, (_, i) => ({
day: day(i),
dayTrimp: 60,
}));
const a = computeTrainingLoad(rows, day(29));
const b = computeTrainingLoad(rows.slice(0, 30), day(29));
expect(a).toEqual(b);
});
});
describe("readTrainingLoad", () => {
it("describes the day the strain run scored", async () => {
const { vi } = await import("vitest");
vi.resetModules();
const findMany = vi.fn(async () => []);
vi.doMock("@/lib/db", () => ({
prisma: { strainTrimpCache: { findMany } },
}));
const { readTrainingLoad } = await import("../training-load");
const { scoreDayKey } = await import("@/lib/insights/score-row");
const now = new Date("2026-10-21T01:30:00.000Z");
await readTrainingLoad("u1", now);
const args = (findMany.mock.calls[0] as unknown[])[0] as {
where: { day: { lte: string } };
};
expect(args.where.day.lte).toBe(scoreDayKey(now));
vi.doUnmock("@/lib/db");
});
});
+183 -1
View File
@@ -30,6 +30,8 @@ import {
} from "@/lib/analytics/compliance";
import {
FACTOR_CHANNEL_PREFIX,
INDOOR_CLIMATE_TARGETS,
INDOOR_CLIMATE_TYPES,
MEDICATION_COMPLIANCE_CHANNEL_KEY,
MOVEMENT_AWAY_CHANNEL_KEY,
MOVEMENT_DISTANCE_CHANNEL_KEY,
@@ -46,7 +48,12 @@ import {
} from "@/lib/environment/fields";
import { isAirQualityActive } from "@/lib/environment/open-meteo-air-quality";
import {
buildAlcoholDailySeries,
buildComplianceDailySeries,
buildNutrientDailySeries,
buildOvernightClimateSeries,
buildTrainingLoadSeries,
buildWorkoutMinutesSeries,
buildSymptomEventDailySeries,
buildSymptomSeverityDailySeries,
type SymptomDayLogRow,
@@ -62,7 +69,14 @@ import {
pickMainNightAndNaps,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { isNearUtc, shiftDateKey, userDayKey } from "@/lib/tz/format";
import {
isNearUtc,
isValidTimezone,
shiftDateKey,
userDayKey,
} from "@/lib/tz/format";
import { MIN_PAIRED_N } from "@/lib/insights/correlations";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import type {
@@ -1155,3 +1169,171 @@ export async function fetchLabDraws(
}
return draws;
}
// ─── v1.44 — the extended channel families ─────────────────────────────────
/**
* Upper bound on nutrient rows read for one scan: 180 days × 28 codes × two
* sources is about ten thousand; the cap falls on the oldest rows.
*/
const NUTRIENT_READ_CAP = 12_000;
/**
* HYDRATION, CAFFEINE and up to four micronutrients from the nutrient log.
* The caller skips this read when the opt-in `nutrients` module is off.
*/
export async function fetchNutrientSeries(
userId: string,
tz: string,
since: Date,
): Promise<NamedSeries[]> {
const rows = await prisma.nutrientIntakeDay.findMany({
where: { userId, day: { gte: userDayKey(since, tz) } },
orderBy: { day: "desc" },
take: NUTRIENT_READ_CAP,
select: { day: true, nutrient: true, amount: true },
});
return buildNutrientDailySeries(rows, { minDays: MIN_PAIRED_N });
}
/** The mood read the alcohol channel shares its cap with. */
const ALCOHOL_TAG_KEY = "alcohol";
/**
* The ALCOHOL channel from the mood log's `alcohol` tag: a structured tag
* link, or the key in the legacy flat `tags` list. Only the timestamp and the
* tag's presence are read; no note, no score.
*/
export async function fetchAlcoholSeries(
userId: string,
tz: string,
since: Date,
): Promise<NamedSeries> {
const rows = await prisma.moodEntry.findMany({
where: { userId, deletedAt: null, moodLoggedAt: { gte: since } },
orderBy: { moodLoggedAt: "desc" },
take: MOOD_READ_CAP,
select: {
moodLoggedAt: true,
tags: true,
tagLinks: {
where: { moodTag: { key: ALCOHOL_TAG_KEY } },
select: { moodTagId: true },
take: 1,
},
},
});
return buildAlcoholDailySeries(
rows.map((row) => ({
at: row.moodLoggedAt,
tagged: row.tagLinks.length > 0 || flatTagsInclude(row.tags),
})),
tz,
);
}
/** Whether a legacy flat `tags` JSON array names the alcohol tag. */
function flatTagsInclude(raw: string | null): boolean {
if (!raw) return false;
try {
const parsed: unknown = JSON.parse(raw);
return Array.isArray(parsed) && parsed.includes(ALCOHOL_TAG_KEY);
} catch {
return false;
}
}
/** Upper bound on workout rows read for one scan. */
const WORKOUT_READ_CAP = 3000;
/**
* WORKOUT_MINUTES (sessions collapsed across sources by the workout list's
* picker) and TRAINING_LOAD (the strain engine's stored day TRIMP). The caller
* skips this read when the `workouts` module is off.
*/
export async function fetchTrainingSeries(
userId: string,
tz: string,
since: Date,
): Promise<NamedSeries[]> {
const [workouts, account, trimp] = await Promise.all([
prisma.workout.findMany({
where: { userId, startedAt: { gte: since } },
orderBy: [{ startedAt: "asc" }, { id: "asc" }],
take: WORKOUT_READ_CAP,
select: {
id: true,
source: true,
sportType: true,
startedAt: true,
durationSec: true,
},
}),
prisma.user.findUnique({
where: { id: userId },
select: { sourcePriorityJson: true },
}),
prisma.strainTrimpCache.findMany({
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the strain cache is keyed on the UTC score day (scoreDayKey)
where: { userId, day: { gte: since.toISOString().slice(0, 10) } },
orderBy: { day: "asc" },
take: 1000,
select: { day: true, dayTrimp: true },
}),
]);
const sessions = pickCanonicalWorkoutRows(
workouts,
account?.sourcePriorityJson ?? null,
);
return [
buildWorkoutMinutesSeries(sessions, tz),
buildTrainingLoadSeries(trimp),
];
}
/**
* The room-climate channels, one per type, as overnight means keyed on the
* waking day. Postgres folds the readings to one row per local hour (a sensor
* reporting every minute is ~260k rows over the window; the hours are at most
* 180 × 24 per type), so the process never holds the raw readings. The caller
* skips this read when the `environment` module is off.
*/
export async function fetchIndoorClimateSeries(
userId: string,
tz: string,
since: Date,
): Promise<NamedSeries[]> {
const zone = isValidTimezone(tz) ? tz : "UTC";
const rows = await prisma.$queryRaw<
Array<{ type: string; day: string; hour: number; mean: number }>
>`
WITH src AS (
SELECT
m."type"::text AS type,
(m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${zone} AS local_at,
m."value" AS value
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."type" IN ('INDOOR_CO2'::measurement_type, 'INDOOR_TEMPERATURE'::measurement_type, 'INDOOR_HUMIDITY'::measurement_type)
AND m."deleted_at" IS NULL
AND m."measured_at" >= ${since}
)
SELECT
type,
to_char(local_at, 'YYYY-MM-DD') AS day,
EXTRACT(HOUR FROM local_at)::int AS hour,
AVG(value)::double precision AS mean
FROM src
GROUP BY type, day, hour
`;
return buildOvernightClimateSeries(
rows.map((r) => ({
type: r.type,
day: r.day,
hour: Number(r.hour),
mean: Number(r.mean),
})),
INDOOR_CLIMATE_TYPES,
INDOOR_CLIMATE_TARGETS,
);
}
+134 -1
View File
@@ -138,6 +138,20 @@ export function metricFamily(key: string): string {
if (key.startsWith("ENV_")) return "ENVIRONMENT";
if (key.startsWith("MOVEMENT_")) return "MOVEMENT";
if (key.startsWith("CUSTOM_METRIC:")) return key;
// v1.44 — State of Mind is a mood self-report from another app: pairing it
// with the logged mood would correlate one feeling with itself.
if (key === "STATE_OF_MIND_VALENCE") return "MOOD";
// The room-climate readings are one room's air; the training channels one
// training record; the intake channels one supplement log.
if (key.startsWith("INDOOR_")) return "INDOOR";
if (key === WORKOUT_MINUTES_CHANNEL_KEY || key === TRAINING_LOAD_CHANNEL_KEY)
return "TRAINING";
if (
key === HYDRATION_CHANNEL_KEY ||
key === CAFFEINE_CHANNEL_KEY ||
key.startsWith(NUTRIENT_CHANNEL_PREFIX)
)
return "NUTRIENTS";
// v1.21.0 (FDREXTEND) — medication compliance and symptom severity each form
// their OWN single-channel family, so the loop's same-family guard collapses
// only the self-lag (compliance→compliance, symptom→symptom). The returned
@@ -196,6 +210,19 @@ export interface NamedSeries {
* there means the averaged 0–1 window, tested as one hypothesis.
*/
lagDays?: number;
/**
* Behaviour channels only: the outcomes this channel is tested against.
* Absent means every outcome. A channel with one plausible question (the
* bedroom's air and the night's sleep) names it, so the pairs nobody asked
* about do not widen the false-discovery family for every other pair.
*/
targets?: readonly string[];
/**
* Behaviour channels only: how the sentence names the exposure window when
* it is not the default next day. `overnight` is the night's mean, keyed on
* the day the person woke up (`lagDays: 0`).
*/
phrasing?: "overnight";
}
/** One discovered, FDR-surviving correlation pair. */
@@ -325,6 +352,7 @@ interface RawPair {
lagDays: number;
behaviourLabel?: string;
outcomeLabel?: string;
phrasing?: "overnight";
}
/**
@@ -374,6 +402,7 @@ function interpret(
t: Translate,
behaviourLabel?: string,
outcomeLabel?: string,
phrasing?: "overnight",
): string {
const params = {
behaviour: behaviourLabel ?? humanise(behaviour, t),
@@ -389,7 +418,13 @@ function interpret(
tier === "faint" ? "faint" : tier === "high" ? "high" : "moderate";
// Lag 0 is the averaged same-day window of the exposure channels ("on the
// day and the day before"); every other pair reads as next-day.
const template = lagDays === 0 ? "dailySameDay" : "daily";
// The overnight room climate reads as "overnight … that night".
const template =
phrasing === "overnight"
? "dailyOvernight"
: lagDays === 0
? "dailySameDay"
: "daily";
return t(`insights.correlation.${template}.${tierKey}`, params);
}
@@ -417,6 +452,12 @@ function humanise(key: string, t: Translate): string {
.toLowerCase();
return t("insights.correlation.channelFactor", { factor });
}
if (key.startsWith(NUTRIENT_CHANNEL_PREFIX)) {
const code = key.slice(NUTRIENT_CHANNEL_PREFIX.length);
const nutrient =
translateOrNull(t, `nutrients.names.${code}`) ?? code.replace(/_/g, " ");
return t("insights.correlation.channelNutrient", { nutrient });
}
const labelKey = `insights.correlation.channel.${key}`;
// An unmapped channel falls back to a readable prettified form, never a
// raw key.
@@ -463,6 +504,95 @@ export const SYMPTOM_SEVERITY_CHANNEL_KEY = "SYMPTOM_SEVERITY";
export const MOVEMENT_AWAY_CHANNEL_KEY = "MOVEMENT_AWAY";
export const MOVEMENT_DISTANCE_CHANNEL_KEY = "MOVEMENT_DISTANCE";
// ─── v1.44 — the extended channel families ─────────────────────────────────
//
// Folded by the assembler beside the declared matrix rather than into
// `DISCOVERY_BEHAVIOURS` / `DISCOVERY_OUTCOMES`: those two lists also feed the
// per-metric status fingerprint (`status-cache.ts`), and a channel added there
// would make every written assessment card go stale on a reading of a type no
// card is about. Every family below is a behaviour except State of Mind, and
// each degrades to absent below the n ≥ 20 floor like every other channel.
/** Daily water intake (mL), from the nutrient log. Owned by `nutrients`. */
export const HYDRATION_CHANNEL_KEY = "HYDRATION";
/** Daily caffeine intake (mg), from the nutrient log. Owned by `nutrients`. */
export const CAFFEINE_CHANNEL_KEY = "CAFFEINE";
/** One micronutrient's daily intake: `NUTRIENT:<catalog code>`. */
export const NUTRIENT_CHANNEL_PREFIX = "NUTRIENT:";
/**
* Days with alcohol, from the `alcohol` tag on mood entries: 1 on a day with a
* tagged entry, 0 on a day with entries and no tag, nothing on a day without
* an entry. Owned by `mood`. AUDIT-C is not a channel: it is a questionnaire
* answered every few months about the past year, and has no day to pair.
*/
export const ALCOHOL_CHANNEL_KEY = "ALCOHOL";
/** Minutes of workout per day, one per session. Owned by `workouts`. */
export const WORKOUT_MINUTES_CHANNEL_KEY = "WORKOUT_MINUTES";
/** The day's heart-rate training impulse (TRIMP). Owned by `workouts`. */
export const TRAINING_LOAD_CHANNEL_KEY = "TRAINING_LOAD";
/** Apple Health day totals read as behaviours (minutes per day). */
export const EXTENDED_BEHAVIOUR_TYPES = [
"EXERCISE_MINUTES",
"STAND_MINUTES",
"MINDFUL_MINUTES",
] as const;
/** Apple Health readings read as outcomes. */
export const EXTENDED_OUTCOME_TYPES = ["STATE_OF_MIND_VALENCE"] as const;
/**
* Room-climate readings, paired with the night's sleep only. The value is the
* mean of the readings between 21:00 and 07:00 local time, keyed on the day
* the person woke up, so it pairs with that night's sleep at lag 0.
*/
export const INDOOR_CLIMATE_TYPES = [
"INDOOR_CO2",
"INDOOR_TEMPERATURE",
"INDOOR_HUMIDITY",
] as const;
export const INDOOR_CLIMATE_TARGETS: readonly string[] = ["SLEEP_DURATION"];
/** Every fixed channel key the extended families can write. */
export const EXTENDED_CHANNEL_KEYS: readonly string[] = [
HYDRATION_CHANNEL_KEY,
CAFFEINE_CHANNEL_KEY,
ALCOHOL_CHANNEL_KEY,
WORKOUT_MINUTES_CHANNEL_KEY,
TRAINING_LOAD_CHANNEL_KEY,
...EXTENDED_BEHAVIOUR_TYPES,
...EXTENDED_OUTCOME_TYPES,
...INDOOR_CLIMATE_TYPES,
];
/**
* Channels whose pairs are statistics for the person only and never reach a
* model: the Coach tool, the period narrative and the assessment cards drop
* every pair that touches one. State of Mind and mindfulness sit in the
* report's sensitive group and are kept out of the Coach snapshot and MCP;
* alcohol reaches a model only through the health profile's own opt-in.
*
* The scan itself is the same on every surface, so the statistics of the
* remaining pairs do not depend on who is reading; the fence is applied to
* the result.
*/
export const MODEL_FENCED_CHANNELS: ReadonlySet<string> = new Set([
"STATE_OF_MIND_VALENCE",
"MINDFUL_MINUTES",
ALCOHOL_CHANNEL_KEY,
]);
/** Drop the pairs that touch a {@link MODEL_FENCED_CHANNELS} channel. */
export function withoutModelFencedPairs<
T extends { behaviour: string; outcome: string },
>(pairs: readonly T[]): T[] {
return pairs.filter(
(p) =>
!MODEL_FENCED_CHANNELS.has(p.behaviour) &&
!MODEL_FENCED_CHANNELS.has(p.outcome),
);
}
/**
* v1.21.0 (FDREXTEND) — discovery channels that are NOT `MeasurementType` enum
* values: each is backed by a different model (MOOD → MoodEntry,
@@ -543,6 +673,7 @@ export function discoverCorrelations(
// auto-correlation tautology, not a cross-domain insight; excluding it
// before testing stops it crowding out genuine cross-metric links.
if (metricFamily(b.key) === metricFamily(o.key)) continue;
if (b.targets && !b.targets.includes(o.key)) continue;
const pairLag = b.lagDays ?? lagDays;
const pairs = lagJoinPairs(b.points, o.points, pairLag);
if (pairs.length < minPairs) continue;
@@ -558,6 +689,7 @@ export function discoverCorrelations(
lagDays: pairLag,
behaviourLabel: b.label,
outcomeLabel: o.label,
...(b.phrasing ? { phrasing: b.phrasing } : {}),
});
}
}
@@ -612,6 +744,7 @@ export function discoverCorrelations(
translate,
t.behaviourLabel,
t.outcomeLabel,
t.phrasing,
),
lagDays: t.lagDays,
}))
@@ -38,8 +38,14 @@ import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz";
import { shiftDateKey } from "@/lib/tz/format";
import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history";
import {
ALCOHOL_CHANNEL_KEY,
CAFFEINE_CHANNEL_KEY,
HYDRATION_CHANNEL_KEY,
MEDICATION_COMPLIANCE_CHANNEL_KEY,
NUTRIENT_CHANNEL_PREFIX,
SYMPTOM_SEVERITY_CHANNEL_KEY,
TRAINING_LOAD_CHANNEL_KEY,
WORKOUT_MINUTES_CHANNEL_KEY,
type DailySeriesPoint,
type NamedSeries,
} from "@/lib/insights/correlation-discovery";
@@ -244,3 +250,239 @@ export function buildSymptomEventDailySeries(args: {
points,
};
}
// ─── v1.44 — the extended channel families ─────────────────────────────────
/** One stored nutrient day total (one row per day, nutrient and source). */
export interface NutrientDayRow {
day: string;
nutrient: string;
amount: number;
}
/** Ceiling on `NUTRIENT:<code>` channels in one scan. */
export const MAX_MICRONUTRIENT_CHANNELS = 4;
/**
* The intake channels from the nutrient log: water as HYDRATION, caffeine as
* CAFFEINE, and at most {@link MAX_MICRONUTRIENT_CHANNELS} micronutrients as
* `NUTRIENT:<code>`.
*
* A day's amount is the sum across its sources (a manual water entry and the
* Apple Health total are two rows, and reads sum them). A day without a row is
* no point: the log does not say whether nothing was taken or nothing was
* recorded. The micronutrients admitted are those with at least `minDays`
* logged days and any day-to-day variance, the most-logged first (code as the
* tie-break): a daily supplement at a fixed dose is a constant, and a
* constant cannot correlate with anything. Pure.
*/
export function buildNutrientDailySeries(
rows: readonly NutrientDayRow[],
opts: { minDays: number },
): NamedSeries[] {
const byNutrient = new Map<string, Map<string, number>>();
for (const row of rows) {
if (!Number.isFinite(row.amount)) continue;
const days = byNutrient.get(row.nutrient) ?? new Map<string, number>();
days.set(row.day, (days.get(row.day) ?? 0) + row.amount);
byNutrient.set(row.nutrient, days);
}
const pointsOf = (code: string): DailySeriesPoint[] =>
[...(byNutrient.get(code) ?? new Map<string, number>()).entries()]
.map(([day, value]) => ({ day, value }))
.sort((a, b) => (a.day < b.day ? -1 : 1));
const out: NamedSeries[] = [
{
key: HYDRATION_CHANNEL_KEY,
role: "behaviour",
points: pointsOf("water"),
},
{
key: CAFFEINE_CHANNEL_KEY,
role: "behaviour",
points: pointsOf("caffeine"),
},
];
const candidates = [...byNutrient.keys()]
.filter((code) => code !== "water" && code !== "caffeine")
.map((code) => ({ code, points: pointsOf(code) }))
.filter(
({ points }) =>
points.length >= opts.minDays &&
points.some((p) => p.value !== points[0].value),
)
.sort(
(a, b) => b.points.length - a.points.length || (a.code < b.code ? -1 : 1),
)
.slice(0, MAX_MICRONUTRIENT_CHANNELS);
for (const { code, points } of candidates) {
out.push({
key: `${NUTRIENT_CHANNEL_PREFIX}${code}`,
role: "behaviour",
points,
});
}
return out;
}
/** One mood entry as the alcohol channel reads it. */
export interface AlcoholTagEntry {
at: Date;
tagged: boolean;
}
/** Fewest tagged days before the alcohol channel is offered at all. */
export const MIN_ALCOHOL_DAYS = 5;
/**
* The ALCOHOL behaviour series: per day with at least one mood entry, 1 when
* any entry that day carries the `alcohol` tag and 0 otherwise. A day without
* an entry is no point. Below {@link MIN_ALCOHOL_DAYS} tagged days (or with no
* untagged day to compare against) the series is empty: a handful of ones is
* what a spurious point-biserial r is made of. Pure.
*/
export function buildAlcoholDailySeries(
entries: readonly AlcoholTagEntry[],
tz: string,
): NamedSeries {
const byDay = new Map<string, number>();
for (const entry of entries) {
const day = dayKey(entry.at, tz);
byDay.set(day, Math.max(byDay.get(day) ?? 0, entry.tagged ? 1 : 0));
}
const points = [...byDay.entries()]
.map(([day, value]) => ({ day, value }))
.sort((a, b) => (a.day < b.day ? -1 : 1));
const tagged = points.filter((p) => p.value === 1).length;
const usable = tagged >= MIN_ALCOHOL_DAYS && tagged < points.length;
return {
key: ALCOHOL_CHANNEL_KEY,
role: "behaviour",
points: usable ? points : [],
};
}
/** One canonical workout session as the training channels read it. */
export interface WorkoutSessionRow {
startedAt: Date;
durationSec: number;
}
/**
* Fill the days between the first and the last key with 0. Inside that span
* the person was recording training, so a day without a session is a rest
* day; outside it nothing says whether they were recording at all. Pure.
*/
function zeroFillSpan(byDay: ReadonlyMap<string, number>): DailySeriesPoint[] {
const keys = [...byDay.keys()].sort();
const points: DailySeriesPoint[] = [];
if (keys.length === 0) return points;
const last = keys[keys.length - 1];
for (let day = keys[0]; day <= last; day = shiftDateKey(day, 1)) {
points.push({ day, value: byDay.get(day) ?? 0 });
}
return points;
}
/**
* WORKOUT_MINUTES: the day's summed session minutes (user zone, by start),
* over sessions the caller has already collapsed across sources, zero-filled
* between the first and the last session. Pure.
*/
export function buildWorkoutMinutesSeries(
sessions: readonly WorkoutSessionRow[],
tz: string,
): NamedSeries {
const byDay = new Map<string, number>();
for (const s of sessions) {
if (!Number.isFinite(s.durationSec) || s.durationSec <= 0) continue;
const day = dayKey(s.startedAt, tz);
byDay.set(day, (byDay.get(day) ?? 0) + s.durationSec / 60);
}
return {
key: WORKOUT_MINUTES_CHANNEL_KEY,
role: "behaviour",
points: zeroFillSpan(byDay),
};
}
/**
* TRAINING_LOAD: the stored day-total TRIMP, zero-filled between the first and
* the last stored day. The nightly strain run writes a row only on a scored
* day, so a missing day inside the span carried no training impulse. The day
* keys are the strain engine's UTC days (the same tolerance the DAY rollups
* carry). Pure.
*/
export function buildTrainingLoadSeries(
rows: ReadonlyArray<{ day: string; dayTrimp: number }>,
): NamedSeries {
const byDay = new Map<string, number>();
for (const row of rows) {
if (!Number.isFinite(row.dayTrimp) || row.dayTrimp < 0) continue;
byDay.set(row.day, row.dayTrimp);
}
return {
key: TRAINING_LOAD_CHANNEL_KEY,
role: "behaviour",
points: zeroFillSpan(byDay),
};
}
/** One local hour's mean of one room-climate type. */
export interface ClimateHourRow {
type: string;
/** Local calendar day of the hour, YYYY-MM-DD. */
day: string;
/** Local hour, 0–23. */
hour: number;
mean: number;
}
/** The overnight window, local time: from this hour … */
export const OVERNIGHT_FROM_HOUR = 21;
/** … until (exclusive) this hour of the next morning. */
export const OVERNIGHT_TO_HOUR = 7;
/** Fewest hours inside the window before a night has a value. */
export const MIN_OVERNIGHT_HOURS = 4;
/**
* One series per room-climate type: the night's mean of its hourly means, the
* night running 21:00 to 07:00 local time and keyed on the morning's day, the
* key the sleep series uses. Averaging hours rather than readings keeps a
* sensor that reports every minute from outweighing one that reports every
* quarter hour. A night with fewer than {@link MIN_OVERNIGHT_HOURS} hours has
* no point. Each series pairs with the night's sleep only, at lag 0. Pure.
*/
export function buildOvernightClimateSeries(
rows: readonly ClimateHourRow[],
types: readonly string[],
targets: readonly string[],
): NamedSeries[] {
const nights = new Map<string, Map<string, { sum: number; n: number }>>();
for (const row of rows) {
if (!Number.isFinite(row.mean)) continue;
let night: string;
if (row.hour >= OVERNIGHT_FROM_HOUR) night = shiftDateKey(row.day, 1);
else if (row.hour < OVERNIGHT_TO_HOUR) night = row.day;
else continue;
const byNight =
nights.get(row.type) ?? new Map<string, { sum: number; n: number }>();
const acc = byNight.get(night) ?? { sum: 0, n: 0 };
acc.sum += row.mean;
acc.n += 1;
byNight.set(night, acc);
nights.set(row.type, byNight);
}
return types.map((type) => ({
key: type,
role: "behaviour" as const,
lagDays: 0,
phrasing: "overnight" as const,
targets,
points: [...(nights.get(type) ?? new Map()).entries()]
.filter(([, acc]) => acc.n >= MIN_OVERNIGHT_HOURS)
.map(([day, acc]) => ({ day, value: acc.sum / acc.n }))
.sort((a, b) => (a.day < b.day ? -1 : 1)),
}));
}
+20 -1
View File
@@ -388,7 +388,7 @@ function roundTo(value: number, digits: number): number {
* gate is no longer load-bearing for correctness — only for sample
* adequacy.
*/
function twoSidedPFromT(absT: number, df: number): number {
export function twoSidedPFromT(absT: number, df: number): number {
if (!Number.isFinite(absT)) return 0;
if (df <= 0) return 1;
if (absT === 0) return 1;
@@ -396,6 +396,25 @@ function twoSidedPFromT(absT: number, df: number): number {
return regularizedIncompleteBeta(x, df / 2, 0.5);
}
/**
* The two-sided critical t at `alpha` with `df` degrees of freedom: the t
* whose two-sided p equals `alpha` (12.706 at df 1, 4.303 at df 2, 1.960 as
* df grows, for alpha 0.05). Bisection over {@link twoSidedPFromT}, which is
* monotone in t; 200 halvings of [0, 1e6] reach double precision. Null for a
* df below 1, where no interval exists.
*/
export function criticalTTwoSided(df: number, alpha = 0.05): number | null {
if (!(df >= 1) || !(alpha > 0 && alpha < 1)) return null;
let lo = 0;
let hi = 1e6;
for (let i = 0; i < 200; i += 1) {
const mid = (lo + hi) / 2;
if (twoSidedPFromT(mid, df) > alpha) lo = mid;
else hi = mid;
}
return (lo + hi) / 2;
}
// ── Welch two-sample t-test (unequal variances) ────────────────────
export interface WelchOk {
@@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const readDayMeanSeries = vi.fn();
vi.mock("@/lib/insights/derived/baseline", () => ({
readDayMeanSeries: (...args: unknown[]) => readDayMeanSeries(...args),
}));
vi.mock("@/lib/rollups/measurement-coverage", () => ({
probeRollupCoverage: vi.fn(async () => new Map()),
}));
import {
buildChangepointSignals,
buildExtendedChangepointSignals,
CHANGEPOINT_WINDOW_DAYS,
EXTENDED_CHANGEPOINT_METRICS,
EXTENDED_CHANGEPOINT_WINDOW_DAYS,
} from "../changepoint";
import { shiftDateKey } from "@/lib/tz/format";
const NOW = new Date("2026-10-21T08:00:00.000Z");
/** 20 days at `a`, then 20 at `b`, a day apart from 2026-06-01. */
function step(a: number, b: number) {
return Array.from({ length: 40 }, (_, i) => ({
day: shiftDateKey("2026-06-01", i),
mean: i < 20 ? a : b,
}));
}
beforeEach(() => {
readDayMeanSeries.mockReset();
});
describe("buildExtendedChangepointSignals", () => {
it("scans every extended metric over 180 days and reports segment sizes", async () => {
readDayMeanSeries.mockImplementation(async (_u: string, metric: string) =>
metric === "RESPIRATORY_RATE"
? { points: step(14, 17), source: "DAY" }
: { points: [], source: "DAY" },
);
const shifts = await buildExtendedChangepointSignals("u1", NOW, {});
const read = readDayMeanSeries.mock.calls.map((c) => c[1]);
expect(read).toEqual([...EXTENDED_CHANGEPOINT_METRICS]);
for (const call of readDayMeanSeries.mock.calls) {
expect(call[2]).toBe(EXTENDED_CHANGEPOINT_WINDOW_DAYS);
}
expect(shifts).toEqual([
expect.objectContaining({
metric: "RESPIRATORY_RATE",
breakDate: "2026-06-21",
beforeMean: 14,
afterMean: 17,
direction: "up",
beforeDays: 20,
afterDays: 20,
}),
]);
});
it("leaves a metric unread when its module is off", async () => {
readDayMeanSeries.mockResolvedValue({ points: [], source: "DAY" });
await buildExtendedChangepointSignals("u1", NOW, {
recovery: false,
glucose: false,
});
const read = readDayMeanSeries.mock.calls.map((c) => c[1]);
expect(read).not.toContain("HRV_RMSSD");
expect(read).not.toContain("RESTING_HEART_RATE");
expect(read).not.toContain("BLOOD_GLUCOSE");
expect(read).toContain("WEIGHT");
});
it("caps at five, strongest first", async () => {
let k = 0;
readDayMeanSeries.mockImplementation(async () => {
k += 1;
return { points: step(10, 10 + k), source: "DAY" };
});
const shifts = await buildExtendedChangepointSignals("u1", NOW, {});
expect(shifts).toHaveLength(5);
const magnitudes = shifts.map((s) => s.magnitude);
expect([...magnitudes].sort((a, b) => b - a)).toEqual(magnitudes);
});
});
describe("buildChangepointSignals (the Coach's scan)", () => {
it("keeps five metrics, 90 days and its shape", async () => {
readDayMeanSeries.mockResolvedValue({
points: step(56, 63),
source: "DAY",
});
const shifts = await buildChangepointSignals("u1", NOW);
expect(readDayMeanSeries).toHaveBeenCalledTimes(5);
expect(readDayMeanSeries.mock.calls[0][2]).toBe(CHANGEPOINT_WINDOW_DAYS);
expect(shifts).toHaveLength(2);
expect(Object.keys(shifts[0]).sort()).toEqual([
"afterMean",
"beforeMean",
"breakDate",
"direction",
"magnitude",
"metric",
]);
});
});
@@ -35,6 +35,11 @@ vi.mock("@/lib/modules/gate", () => ({
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/db", () => ({
prisma: {
// v1.44 — the extended channel families (intake, training, room climate).
nutrientIntakeDay: { findMany: async () => [] },
workout: { findMany: async () => [] },
strainTrimpCache: { findMany: async () => [] },
$queryRaw: async () => [],
measurement: {
findMany: (a: unknown) => measurementFindMany(a),
findFirst: (a: unknown) => measurementFindFirst(a),
+105 -13
View File
@@ -28,6 +28,8 @@
import type { MeasurementType } from "@/generated/prisma/client";
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
import { readDayMeanSeries } from "@/lib/insights/derived/baseline";
import { moduleForMeasurementType } from "@/lib/modules/measurement-scope";
import type { SurfaceModuleMap } from "@/lib/modules/surface";
const MAD_TO_SIGMA = 1.4826;
@@ -184,24 +186,60 @@ const MAX_CHANGEPOINTS = 2;
const round1 = (n: number): number => Math.round(n * 10) / 10;
/**
* Read the DAY-mean series for a small set of stable metrics and surface any
* validated level shift. Best-effort + fault-isolated per metric: a read
* failure on one metric never sinks the others. Returns the strongest
* changepoints (by magnitude), capped, or an empty array when nothing fires.
* v1.44 — the metrics the health-status surface scans: the five above plus
* every other vital with a stable daily level and enough readings to carry a
* 180-day window. Activity volumes, audio and sleep stay out: the first two are
* day-to-day behaviour rather than a level, and a sleep "day mean" over stage
* rows is not a night. Wrist temperature stays out for the same privacy reason
* it is not a correlation channel.
*/
export async function buildChangepointSignals(
userId: string,
now: Date = new Date(),
): Promise<ChangepointSignal[]> {
const coverage = await probeRollupCoverage(userId);
const found: ChangepointSignal[] = [];
export const EXTENDED_CHANGEPOINT_METRICS: readonly MeasurementType[] = [
...CHANGEPOINT_METRICS,
"HRV_RMSSD",
"BLOOD_PRESSURE_DIA",
"PULSE",
"BODY_FAT",
"RESPIRATORY_RATE",
"OXYGEN_SATURATION",
"WALKING_HEART_RATE_AVERAGE",
"VO2_MAX",
];
for (const metric of CHANGEPOINT_METRICS) {
/** Window of the extended scan (days). */
export const EXTENDED_CHANGEPOINT_WINDOW_DAYS = 180;
/** At most this many shifts on the health-status surface. */
const MAX_EXTENDED_CHANGEPOINTS = 5;
/** A changepoint with the day counts of the two segments it compares. */
export interface ExtendedChangepointSignal extends ChangepointSignal {
/** Days with a value before the break. */
beforeDays: number;
/** Days with a value from the break on. */
afterDays: number;
}
/**
* Scan a metric set for one validated shift each, fault-isolated per metric,
* strongest first, capped. One per-day read per metric (DAY rollups, or the
* per-day SQL fold for a window past the DAY tier), never raw readings in
* process, so the cost is a row per day per metric whatever the density.
*/
async function scanChangepoints(
userId: string,
now: Date,
metrics: readonly MeasurementType[],
windowDays: number,
max: number,
): Promise<ExtendedChangepointSignal[]> {
const coverage = await probeRollupCoverage(userId);
const found: ExtendedChangepointSignal[] = [];
for (const metric of metrics) {
try {
const { points } = await readDayMeanSeries(
userId,
metric,
CHANGEPOINT_WINDOW_DAYS,
windowDays,
now,
coverage,
);
@@ -217,6 +255,8 @@ export async function buildChangepointSignals(
afterMean: round1(shift.afterMean),
direction: shift.direction,
magnitude: round1(shift.magnitude),
beforeDays: shift.breakIndex + 1,
afterDays: points.length - shift.breakIndex - 1,
});
} catch {
// Fault-isolated — skip this metric, keep scanning.
@@ -224,5 +264,57 @@ export async function buildChangepointSignals(
}
found.sort((a, b) => b.magnitude - a.magnitude);
return found.slice(0, MAX_CHANGEPOINTS);
return found.slice(0, max);
}
/**
* v1.44 — the health-status scan: {@link EXTENDED_CHANGEPOINT_METRICS} over
* {@link EXTENDED_CHANGEPOINT_WINDOW_DAYS} days, a metric whose module is off
* left unread. The Coach keeps {@link buildChangepointSignals}: what reaches a
* model is not widened by this.
*/
export async function buildExtendedChangepointSignals(
userId: string,
now: Date,
modules: SurfaceModuleMap,
): Promise<ExtendedChangepointSignal[]> {
const metrics = EXTENDED_CHANGEPOINT_METRICS.filter((metric) => {
const owner = moduleForMeasurementType(metric);
return owner === null || modules[owner] !== false;
});
return scanChangepoints(
userId,
now,
metrics,
EXTENDED_CHANGEPOINT_WINDOW_DAYS,
MAX_EXTENDED_CHANGEPOINTS,
);
}
/**
* Read the DAY-mean series for a small set of stable metrics and surface any
* validated level shift. Best-effort + fault-isolated per metric: a read
* failure on one metric never sinks the others. Returns the strongest
* changepoints (by magnitude), capped, or an empty array when nothing fires.
*/
export async function buildChangepointSignals(
userId: string,
now: Date = new Date(),
): Promise<ChangepointSignal[]> {
const found = await scanChangepoints(
userId,
now,
CHANGEPOINT_METRICS,
CHANGEPOINT_WINDOW_DAYS,
MAX_CHANGEPOINTS,
);
// The Coach snapshot's shape is unchanged: no segment day counts.
return found.map((signal) => ({
metric: signal.metric,
breakDate: signal.breakDate,
beforeMean: signal.beforeMean,
afterMean: signal.afterMean,
direction: signal.direction,
magnitude: signal.magnitude,
}));
}
+73
View File
@@ -36,18 +36,28 @@
*/
import type { MeasurementType } from "@/generated/prisma/client";
import {
ALCOHOL_CHANNEL_KEY,
DISCOVERY_BEHAVIOURS,
DISCOVERY_OUTCOMES,
discoveryMeasurementTypes,
EXTENDED_BEHAVIOUR_TYPES,
EXTENDED_OUTCOME_TYPES,
HYDRATION_CHANNEL_KEY,
INDOOR_CLIMATE_TYPES,
MEDICATION_COMPLIANCE_CHANNEL_KEY,
MOVEMENT_AWAY_CHANNEL_KEY,
WORKOUT_MINUTES_CHANNEL_KEY,
SYMPTOM_SEVERITY_CHANNEL_KEY,
type DailySeriesPoint,
type NamedSeries,
} from "@/lib/insights/correlation-discovery";
import {
buildMeasurementDailySeries,
fetchAlcoholSeries,
fetchComplianceSeries,
fetchIndoorClimateSeries,
fetchNutrientSeries,
fetchTrainingSeries,
fetchCustomMetricBehaviourSeries,
fetchSymptomEventSeries,
fetchEnvironmentSeries,
@@ -162,6 +172,14 @@ export interface DiscoveryMatrixDiagnostics {
moodFactorChannels: number;
/** `SYMPTOM:*` channels read (before the module mask). */
symptomEventChannels: number;
/** Stored daily points summed across the intake channels (v1.44). */
nutrientDays: number;
/** Days in the ALCOHOL series (0 below its tagged-day floor). */
alcoholDays: number;
/** Daily points summed across the two training channels. */
trainingDays: number;
/** Nights summed across the room-climate channels. */
indoorNights: number;
}
export interface DiscoveryMatrix {
@@ -260,6 +278,11 @@ async function readMeasurementDaily(
};
}
/** Daily points summed across a family's series. */
function daysIn(family: readonly NamedSeries[]): number {
return family.reduce((sum, s) => sum + s.points.length, 0);
}
/**
* Build the full discovery matrix for a user over a window.
*
@@ -292,8 +315,18 @@ export async function assembleDiscoveryMatrix(
...discoveryMeasurementTypes(DISCOVERY_BEHAVIOURS),
...discoveryMeasurementTypes(DISCOVERY_OUTCOMES),
...(opts.extraMeasurementTypes ?? []),
// v1.44 — the Apple Health day totals and State of Mind read through the
// same measurement path; they are left out of `byMetric` below.
...EXTENDED_BEHAVIOUR_TYPES,
...EXTENDED_OUTCOME_TYPES,
]),
) as MeasurementType[];
const extendedTypes = new Set<string>([
...EXTENDED_BEHAVIOUR_TYPES,
...EXTENDED_OUTCOME_TYPES,
]);
const visible = (channelKey: string): boolean =>
isSurfaceVisible(correlationChannelSurfaceId(channelKey), opts.modules);
const [
measurements,
@@ -304,6 +337,10 @@ export async function assembleDiscoveryMatrix(
customMetricSeries,
symptomEventSeries,
movementSeries,
nutrientSeries,
alcoholSeries,
trainingSeries,
indoorSeries,
] = await Promise.all([
readMeasurementDaily(userId, tz, since, measurementTypes, fetchMode),
readMood(userId, tz, since, opts.includeMoodFactors === true),
@@ -329,6 +366,20 @@ export async function assembleDiscoveryMatrix(
)
? fetchMovementSeries(userId, tz, since)
: Promise.resolve([] as NamedSeries[]),
// v1.44 — each extended family is read only while its module is on; the
// mask below would drop it anyway.
visible(HYDRATION_CHANNEL_KEY)
? fetchNutrientSeries(userId, tz, since)
: Promise.resolve([] as NamedSeries[]),
visible(ALCOHOL_CHANNEL_KEY)
? fetchAlcoholSeries(userId, tz, since)
: Promise.resolve(null),
visible(WORKOUT_MINUTES_CHANNEL_KEY)
? fetchTrainingSeries(userId, tz, since)
: Promise.resolve([] as NamedSeries[]),
visible(INDOOR_CLIMATE_TYPES[0])
? fetchIndoorClimateSeries(userId, tz, since)
: Promise.resolve([] as NamedSeries[]),
]);
const { factorSeries } = mood;
@@ -366,6 +417,21 @@ export async function assembleDiscoveryMatrix(
// channel and its own family, capped at eight; the illness module owns them
// through the `SYMPTOM:` prefix rule in the surface map.
series.push(...symptomEventSeries);
// v1.44 — the extended families, folded after every earlier family so the
// ranking of the pairs that existed before is unchanged on a tie. The
// intake, alcohol and training channels pair D → D+1; the room climate is
// the overnight mean keyed on the waking day and pairs with that night's
// sleep only. Exercise, stand and mindful minutes are behaviours; State of
// Mind is an outcome in the mood family.
for (const key of EXTENDED_BEHAVIOUR_TYPES) {
series.push({ key, role: "behaviour", points: points(key) });
}
for (const key of EXTENDED_OUTCOME_TYPES) {
series.push({ key, role: "outcome", points: points(key) });
}
series.push(...nutrientSeries);
if (alcoholSeries) series.push(alcoholSeries);
series.push(...trainingSeries, ...indoorSeries);
// A factor is plausibly both a lag source ("rated work today → next-day
// sleep") and a lag target ("steps today → next-day rated energy"), so it
// enters as both roles. The engine skips self-pairs — every factor shares
@@ -376,6 +442,9 @@ export async function assembleDiscoveryMatrix(
}
const byMetric = new Map(measurements.byType);
// The extended types feed the scan only. `byMetric` is what the narrative
// cites deltas from, and none of them is a metric it describes.
for (const key of extendedTypes) byMetric.delete(key);
if (mood.moodDaily.length > 0) byMetric.set("MOOD", mood.moodDaily);
for (const [key, factorPoints] of factorSeries) {
byMetric.set(key, factorPoints);
@@ -408,6 +477,10 @@ export async function assembleDiscoveryMatrix(
),
moodFactorChannels: factorSeries.size,
symptomEventChannels: symptomEventSeries.length,
nutrientDays: daysIn(nutrientSeries),
alcoholDays: alcoholSeries?.points.length ?? 0,
trainingDays: daysIn(trainingSeries),
indoorNights: daysIn(indoorSeries),
},
};
}
+27 -11
View File
@@ -28,6 +28,7 @@ import {
readRollupBuckets,
ROLLUP_FOLD_WINDOW_MS,
} from "@/lib/rollups/measurement-rollups";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import { deriveBucketedTypes } from "@/lib/signals/adapters/correlation";
import type {
AggregatedFeatures,
@@ -535,17 +536,32 @@ export async function readWorkoutsBlock(
now: number,
): Promise<AggregatedFeatures["workouts"] | undefined> {
const since = new Date(now - WORKOUT_WINDOW_DAYS * MS_PER_DAY);
const rows = await prisma.workout.findMany({
where: { userId, startedAt: { gte: since } },
orderBy: { startedAt: "desc" },
take: 2000,
select: {
sportType: true,
startedAt: true,
durationSec: true,
totalDistanceM: true,
},
});
const [stored, account] = await Promise.all([
prisma.workout.findMany({
where: { userId, startedAt: { gte: since } },
orderBy: [{ startedAt: "desc" }, { id: "asc" }],
take: 2000,
select: {
source: true,
sportType: true,
startedAt: true,
durationSec: true,
totalDistanceM: true,
},
}),
prisma.user.findUnique({
where: { id: userId },
select: { sourcePriorityJson: true },
}),
]);
// One session synced by two services (or re-sent by HealthKit) is one
// workout: the same collapse the workout list applies, so the count and the
// minutes here match what the person sees there. The picker returns its rows
// oldest first; the block reads the newest at index 0.
const rows = pickCanonicalWorkoutRows(
stored,
account?.sourcePriorityJson ?? null,
).reverse();
if (rows.length === 0) return undefined;
const tally = (windowDays: number) => {
+10 -1
View File
@@ -46,6 +46,10 @@ export interface HealthStatusShift {
beforeMean: number;
afterMean: number;
direction: "up" | "down";
/** v1.44 — days with a value before the break, when the scan reports it. */
beforeDays?: number;
/** v1.44 — days with a value from the break on. */
afterDays?: number;
}
export interface HealthStatusSummary {
@@ -62,7 +66,9 @@ export interface HealthStatusSummary {
*/
export function summariseHealthStatus(
vitals: readonly VitalDeviation[],
shifts: readonly ChangepointSignal[],
shifts: ReadonlyArray<
ChangepointSignal & { beforeDays?: number; afterDays?: number }
>,
): HealthStatusSummary {
const deviations: HealthStatusDeviation[] = vitals
// Present tense, so present readings only. "Your pulse is above your usual
@@ -88,6 +94,9 @@ export function summariseHealthStatus(
beforeMean: s.beforeMean,
afterMean: s.afterMean,
direction: s.direction,
...(s.beforeDays !== undefined && s.afterDays !== undefined
? { beforeDays: s.beforeDays, afterDays: s.afterDays }
: {}),
}));
return {
@@ -32,6 +32,7 @@ import {
discoverCorrelations,
DISCOVERY_BEHAVIOURS,
DISCOVERY_OUTCOMES,
withoutModelFencedPairs,
} from "@/lib/insights/correlation-discovery";
import { resolveModuleMap } from "@/lib/modules/gate";
import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix";
@@ -133,6 +134,9 @@ export async function getRelevantCorrelationsForMetric(
// written in its place rather than after it; the docstring, the name
// and the `channelKeyForType` guard above all kept saying otherwise.
.filter((d) => d.behaviour === channel || d.outcome === channel)
// The card's prompt is model input: a pair on a fenced channel stays
// on the correlations page.
.filter((d) => withoutModelFencedPairs([d]).length === 1)
.filter((d) => {
const decision = decisionForEvidence(decisions, {
factorKey: d.behaviour,
@@ -38,6 +38,11 @@ vi.mock("@/lib/modules/gate", () => ({
vi.mock("@/lib/db", () => ({
prisma: {
// v1.44 — the extended channel families (intake, training, room climate).
nutrientIntakeDay: { findMany: async () => [] },
workout: { findMany: async () => [] },
strainTrimpCache: { findMany: async () => [] },
$queryRaw: async () => [],
user: { findUnique: (a: unknown) => userFindUnique(a) },
measurement: { findMany: (a: unknown) => measurementFindMany(a) },
moodEntry: { findMany: (a: unknown) => moodFindMany(a) },
@@ -48,6 +48,7 @@ import { annotate } from "@/lib/logging/context";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import {
discoverCorrelations,
withoutModelFencedPairs,
type DailySeriesPoint,
type NamedSeries,
} from "@/lib/insights/correlation-discovery";
@@ -422,7 +423,10 @@ export function assemblePeriodNarrativeContext(
// ── drivers (FDR-surviving correlations, descriptive-only) ──────────────
const discovery = discoverCorrelations(discoverySeries, { locale });
const drivers: NarrativeDriver[] = discovery.discovered.map((d) => ({
// The narrative is model input: pairs on a fenced channel stay out of it.
const drivers: NarrativeDriver[] = withoutModelFencedPairs(
discovery.discovered,
).map((d) => ({
behaviour: d.behaviour,
outcome: d.outcome,
r: d.r,
+90 -14
View File
@@ -72,6 +72,11 @@ import {
upsertScoreRow,
} from "@/lib/insights/score-row";
import { shiftDateKey } from "@/lib/tz/format";
import {
pickCanonicalWorkoutRows,
workoutDuplicateSlotKey,
type WorkoutPickerRow,
} from "@/lib/measurements/pick-canonical-workout-rows";
/** The per-day idempotency-key prefix for a stored Strain score row. */
export const STRAIN_SCORE_EXTERNAL_ID_PREFIX = "strain:";
@@ -413,12 +418,28 @@ export async function computeStrainScore(
});
const dayActiveEnergy = energyRows.reduce((s, r) => s + r.value, 0);
// The day's workouts + their HR series.
const workouts = await prisma.workout.findMany({
where: { userId, startedAt: { gte: dayStart, lt: dayEnd } },
select: { id: true, samples: { select: { samples: true } } },
take: 100,
});
// The day's workouts + their HR series, with the session identity the
// cross-source picker needs: one run recorded by the watch and synced again
// through a second service is one training impulse, not two.
const [workouts, account] = await Promise.all([
prisma.workout.findMany({
where: { userId, startedAt: { gte: dayStart, lt: dayEnd } },
orderBy: [{ startedAt: "asc" }, { id: "asc" }],
select: {
id: true,
source: true,
sportType: true,
startedAt: true,
durationSec: true,
samples: { select: { samples: true } },
},
take: 100,
}),
prisma.user.findUnique({
where: { id: userId },
select: { sourcePriorityJson: true },
}),
]);
const hrMax = profile.ageYears != null ? tanakaHrMax(profile.ageYears) : null;
const canTrimp = hrMax != null && hrRest != null && hrMax > hrRest;
@@ -426,15 +447,24 @@ export async function computeStrainScore(
let dayTrimp = 0;
let workoutsWithSeries = 0;
if (canTrimp) {
for (const w of workouts) {
const trimpOf = (w: (typeof workouts)[number]): number => {
const raw = w.samples?.samples;
if (!Array.isArray(raw)) continue;
const series = raw as unknown as HrSample[];
const trimp = banisterTrimp(series, hrRest!, hrMax!, profile.sex);
if (trimp > 0) {
dayTrimp += trimp;
workoutsWithSeries += 1;
}
if (!Array.isArray(raw)) return 0;
return banisterTrimp(
raw as unknown as HrSample[],
hrRest!,
hrMax!,
profile.sex,
);
};
const sessions = canonicalWorkoutTrimps(
workouts,
account?.sourcePriorityJson ?? null,
trimpOf,
);
for (const trimp of sessions) {
dayTrimp += trimp;
workoutsWithSeries += 1;
}
}
@@ -499,6 +529,52 @@ export async function computeStrainScore(
};
}
/**
* One training impulse per workout SESSION, not per stored row.
*
* The same session can be stored more than once: the watch writes it to Apple
* Health and a second service (Strava, WHOOP, a ring) syncs it again, or
* HealthKit re-sends it under a new identifier. The workout list collapses
* those through `pickCanonicalWorkoutRows`; summing every row here counted the
* session twice in the day's TRIMP while the list showed it once.
*
* The picker chooses the row by source order, which says nothing about heart
* rate: the winning row can be one without a series while its dropped twin
* carries one. So the impulse of a slot is the sum over its picked rows, and
* only when that is zero the largest impulse among the slot's dropped rows.
* Returns the positive impulses, one per session. Pure; exported for tests.
*/
export function canonicalWorkoutTrimps<
T extends WorkoutPickerRow & { id: string },
>(
rows: readonly T[],
priorityJson: unknown,
trimpOf: (row: T) => number,
): number[] {
// By id: the picker may hand back a copy of a row (it fills a missing heart
// rate from a WHOOP twin).
const picked = new Set(
pickCanonicalWorkoutRows(rows, priorityJson).map((row) => row.id),
);
const slots = new Map<string, { picked: number; dropped: number }>();
for (const row of rows) {
const key = workoutDuplicateSlotKey(row);
const slot = slots.get(key) ?? { picked: 0, dropped: 0 };
const trimp = trimpOf(row);
if (trimp > 0) {
if (picked.has(row.id)) slot.picked += trimp;
else slot.dropped = Math.max(slot.dropped, trimp);
}
slots.set(key, slot);
}
const out: number[] = [];
for (const slot of slots.values()) {
const trimp = slot.picked > 0 ? slot.picked : slot.dropped;
if (trimp > 0) out.push(trimp);
}
return out;
}
/**
* Build the Strain profile (age + sex) and resting HR from the user row +
* the latest resting-HR reading. `prisma` is the (worker) client.
+152
View File
@@ -0,0 +1,152 @@
/**
* v1.44 — training load as fitness, fatigue and form, from the stored daily
* training impulse.
*
* The impulse is the Banister TRIMP the nightly strain run already writes to
* `strain_trimp_cache` (one row per scored UTC day; a day without a row
* carried no impulse). Over it run the two exponentially weighted averages
* of the fitness-fatigue model (Banister, Calvert, Savage & Bach 1975; in the
* time-constant form popularised as chronic and acute training load):
*
* fitness CTL_d = CTL_{d−1} + (L_d − CTL_{d−1}) / 42
* fatigue ATL_d = ATL_{d−1} + (L_d − ATL_{d−1}) / 7
* form TSB_d = CTL_{d−1} − ATL_{d−1} (going into day d)
*
* Both averages start at zero on the first stored day, so the figures are
* flagged `warmingUp` until {@link CTL_DAYS} days are covered. The values are
* in TRIMP units: comparable with the person's own past, not with anyone
* else's and not with another app's training-stress score. Descriptive only:
* no performance prediction and no advice. Pure apart from the one read.
*/
import { prisma } from "@/lib/db";
import { shiftDateKey } from "@/lib/tz/format";
export const CTL_DAYS = 42;
export const ATL_DAYS = 7;
/** Days of history before the figures are offered at all. */
export const TRAINING_LOAD_MIN_DAYS = 28;
/** Training days (impulse > 0) before the figures are offered at all. */
export const TRAINING_LOAD_MIN_TRAINING_DAYS = 3;
/** Days of history read; the seed's pull is gone long before. */
export const TRAINING_LOAD_READ_DAYS = 365;
/** Trailing days of the series returned for a chart. */
export const TRAINING_LOAD_SERIES_DAYS = 90;
export interface TrainingLoadPoint {
day: string;
ctl: number;
atl: number;
tsb: number;
}
export type TrainingLoad =
| {
status: "ok";
/** The last scored day the figures describe (UTC day key). */
asOf: string;
ctl: number;
atl: number;
/** Form going into the day after `asOf`: CTL − ATL at `asOf`. */
tsb: number;
daysCovered: number;
trainingDays: number;
warmingUp: boolean;
series: TrainingLoadPoint[];
}
| {
status: "insufficient";
reason: "no_impulse_history" | "too_little_history";
daysCovered: number;
trainingDays: number;
};
const round1 = (x: number) => Math.round(x * 10) / 10;
/**
* Run the model from the first stored day through `asOf` (inclusive), a
* missing day counting as zero impulse. Rows after `asOf` are ignored.
*/
export function computeTrainingLoad(
rows: ReadonlyArray<{ day: string; dayTrimp: number }>,
asOf: string,
): TrainingLoad {
const byDay = new Map<string, number>();
for (const row of rows) {
if (row.day > asOf || !Number.isFinite(row.dayTrimp)) continue;
byDay.set(row.day, Math.max(0, row.dayTrimp));
}
const days = [...byDay.keys()].sort();
const trainingDays = [...byDay.values()].filter((v) => v > 0).length;
if (days.length === 0 || trainingDays === 0) {
return {
status: "insufficient",
reason: "no_impulse_history",
daysCovered: 0,
trainingDays,
};
}
const series: TrainingLoadPoint[] = [];
let ctl = 0;
let atl = 0;
let daysCovered = 0;
for (let day = days[0]; day <= asOf; day = shiftDateKey(day, 1)) {
const tsb = ctl - atl;
const load = byDay.get(day) ?? 0;
ctl += (load - ctl) / CTL_DAYS;
atl += (load - atl) / ATL_DAYS;
daysCovered += 1;
series.push({ day, ctl, atl, tsb });
}
if (
daysCovered < TRAINING_LOAD_MIN_DAYS ||
trainingDays < TRAINING_LOAD_MIN_TRAINING_DAYS
) {
return {
status: "insufficient",
reason: "too_little_history",
daysCovered,
trainingDays,
};
}
return {
status: "ok",
asOf,
ctl: round1(ctl),
atl: round1(atl),
tsb: round1(ctl - atl),
daysCovered,
trainingDays,
warmingUp: daysCovered < CTL_DAYS,
series: series.slice(-TRAINING_LOAD_SERIES_DAYS).map((p) => ({
day: p.day,
ctl: round1(p.ctl),
atl: round1(p.atl),
tsb: round1(p.tsb),
})),
};
}
/** Read the stored impulse and run the model up to the last scored day. */
export async function readTrainingLoad(
userId: string,
now: Date = new Date(),
): Promise<TrainingLoad> {
// The strain run scores the previous UTC day (`scoreDayKey`). Restated
// here rather than imported: `score-row` sits on the score writers' import
// graph, and this read stays off it.
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the strain cache is keyed on the UTC score day
const asOf = new Date(now.getTime() - 86_400_000).toISOString().slice(0, 10);
const rows = await prisma.strainTrimpCache.findMany({
where: {
userId,
day: { gte: shiftDateKey(asOf, -TRAINING_LOAD_READ_DAYS), lte: asOf },
},
orderBy: { day: "asc" },
take: TRAINING_LOAD_READ_DAYS + 1,
select: { day: true, dayTrimp: true },
});
return computeTrainingLoad(rows, asOf);
}
+131
View File
@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import { criticalTTwoSided } from "@/lib/insights/correlations";
import { computeLabTrend } from "../lab-trend";
const YEAR_MS = 365.25 * 86_400_000;
const T0 = Date.UTC(2023, 0, 1);
const atYears = (y: number) => new Date(T0 + y * YEAR_MS);
const readings = (values: Array<[number, number]>) =>
values.map(([y, value]) => ({ takenAt: atYears(y), value }));
describe("criticalTTwoSided", () => {
it("matches the tabled Student t at alpha 0.05", () => {
// df 1: tan(0.95·π/2) exactly.
expect(criticalTTwoSided(1)).toBeCloseTo(Math.tan(0.475 * Math.PI), 6);
expect(criticalTTwoSided(2)).toBeCloseTo(4.302653, 5);
expect(criticalTTwoSided(10)).toBeCloseTo(2.228139, 5);
expect(criticalTTwoSided(1000)).toBeCloseTo(1.962339, 4);
expect(criticalTTwoSided(0)).toBeNull();
});
});
describe("computeLabTrend", () => {
it("needs three readings over at least a year", () => {
expect(
computeLabTrend(
readings([
[0, 1],
[2, 2],
]),
{ low: 0, high: 5 },
atYears(2),
),
).toEqual({
status: "insufficient",
reason: "too_few_values",
n: 2,
spanDays: 731,
});
expect(
computeLabTrend(
readings([
[0, 1],
[0.3, 2],
[0.6, 3],
]),
{ low: 0, high: 5 },
atYears(1),
),
).toMatchObject({ status: "insufficient", reason: "span_too_short", n: 3 });
});
it("projects the exit date of an exact line", () => {
// 100, 110, 120 a year apart: 10 per year, no residual, band top 140.
const trend = computeLabTrend(
readings([
[0, 100],
[1, 110],
[2, 120],
]),
{ low: 70, high: 140 },
atYears(2),
);
expect(trend).toMatchObject({
status: "ok",
n: 3,
slopePerYear: 10,
slopeCi95: [10, 10],
pValue: 0,
fittedNow: 120,
projection: "exits",
exitBound: "high",
// The line meets 140 four years after the first reading.
exitDate: new Date(T0 + 4 * YEAR_MS).toISOString().slice(0, 10),
});
});
it("draws no projection when the slope's interval includes zero", () => {
// 100, 112, 118: slope 9, SSR 6, df 1, SE √3, t = 9/√3.
const trend = computeLabTrend(
readings([
[0, 100],
[1, 112],
[2, 118],
]),
{ low: 70, high: 140 },
atYears(2),
);
if (trend.status !== "ok") throw new Error("expected ok");
const half = Math.tan(0.475 * Math.PI) * Math.sqrt(3);
expect(trend.slopePerYear).toBe(9);
expect(trend.slopeCi95[0]).toBeCloseTo(9 - half, 3);
expect(trend.slopeCi95[1]).toBeCloseTo(9 + half, 3);
// df 1 is the Cauchy: p = (2/π)·atan(√3 / 9).
expect(trend.pValue).toBeCloseTo(
(2 / Math.PI) * Math.atan(Math.sqrt(3) / 9),
5,
);
expect(trend.projection).toBe("no_clear_trend");
expect(trend.exitDate).toBeNull();
});
it("says when the line is already outside, has no band, or exits too late", () => {
const line = readings([
[0, 100],
[1, 110],
[2, 120],
]);
expect(
computeLabTrend(line, { low: 70, high: 115 }, atYears(2)),
).toMatchObject({ projection: "already_outside" });
expect(
computeLabTrend(line, { low: null, high: null }, atYears(2)),
).toMatchObject({ projection: "no_band" });
expect(
computeLabTrend(line, { low: 70, high: 200 }, atYears(2)),
).toMatchObject({ projection: "beyond_horizon", exitDate: null });
// Falling toward a band with no lower bound.
const falling = readings([
[0, 120],
[1, 110],
[2, 100],
]);
expect(
computeLabTrend(falling, { low: null, high: 140 }, atYears(2)),
).toMatchObject({ projection: "no_exit" });
expect(
computeLabTrend(falling, { low: 90, high: 140 }, atYears(2)),
).toMatchObject({ projection: "exits", exitBound: "low" });
});
});
+184
View File
@@ -0,0 +1,184 @@
/**
* v1.44 — the slope of one lab marker over all its readings, and where that
* line would leave the reference band.
*
* A single panel says where a value is; the trend says where it is going.
* The statistic is an ordinary least-squares line through every numeric
* reading of the marker (stored in the marker's unit since v1.40, so the
* series is unit-normalised by construction), x in years. It is reported
* only with at least {@link LAB_TREND_MIN_VALUES} readings spanning at least
* {@link LAB_TREND_MIN_SPAN_DAYS} days: three draws in one month are a
* measurement-noise question, not a trend.
*
* The slope carries its 95 % confidence interval (Student t at n − 2 degrees
* of freedom). A projection is offered only when that interval excludes zero,
* the line is inside the band today and heads toward a bound it reaches
* within {@link LAB_PROJECTION_HORIZON_YEARS} years. It is a straight-line
* extrapolation, labelled as one wherever it is shown, never a forecast of
* the body and never a risk statement: lab values plateau, respond to
* treatment and carry assay noise a line does not know about. With three
* readings the interval is wide (t ≈ 12.7 at one degree of freedom), so the
* projection is rare exactly where the evidence is thin.
*
* Pure. The read lives in the biomarker route.
*/
import { criticalTTwoSided, twoSidedPFromT } from "@/lib/insights/correlations";
import { linearFit } from "@/lib/stats/linear-fit";
import { dateOnlyKey } from "@/lib/tz/date-only";
export const LAB_TREND_MIN_VALUES = 3;
export const LAB_TREND_MIN_SPAN_DAYS = 365;
export const LAB_PROJECTION_HORIZON_YEARS = 3;
const DAY_MS = 86_400_000;
const YEAR_DAYS = 365.25;
export type LabTrendProjection =
/** The line meets a bound within the horizon: `exitDate` is set. */
| "exits"
/** The line heads to a bound but meets it after the horizon. */
| "beyond_horizon"
/** The line heads away from the band edges, or to a side with no bound. */
| "no_exit"
/** The line already sits outside the band today. */
| "already_outside"
/** The slope's interval includes zero: no projection is drawn. */
| "no_clear_trend"
/** The marker has no reference band to leave. */
| "no_band";
export interface LabTrendOk {
status: "ok";
/** Readings in the fit. */
n: number;
firstAt: string;
lastAt: string;
spanDays: number;
/** Change per year in the marker's unit. */
slopePerYear: number;
/** 95 % interval of the slope per year. */
slopeCi95: [number, number];
/** Two-sided p of the slope against zero (t at n − 2 df). */
pValue: number;
/** The line's value on `asOf`. */
fittedNow: number;
asOf: string;
projection: LabTrendProjection;
/** Set only for `exits`: the day the line meets `exitBound`. */
exitDate: string | null;
exitBound: "low" | "high" | null;
}
export interface LabTrendInsufficient {
status: "insufficient";
reason: "too_few_values" | "span_too_short";
n: number;
spanDays: number;
}
export type LabTrend = LabTrendOk | LabTrendInsufficient;
const round = (x: number, digits: number) => {
const f = 10 ** digits;
return Math.round(x * f) / f;
};
/**
* The calendar label of a draw instant. Lab readings carry the day of the
* draw as their meaning (`reading-day.ts`); the exit day is a point on the
* same axis, so both read through the same label.
*/
const dayOf = (ms: number) => dateOnlyKey(new Date(ms));
/**
* Fit the marker's readings and place the line against its band.
* `now` anchors "today" for the fitted value and the horizon.
*/
export function computeLabTrend(
readings: ReadonlyArray<{ takenAt: Date; value: number }>,
band: { low: number | null; high: number | null },
now: Date,
): LabTrend {
const points = readings
.filter((r) => Number.isFinite(r.value))
.map((r) => ({ t: r.takenAt.getTime(), v: r.value }))
.sort((a, b) => a.t - b.t);
const n = points.length;
const spanDays =
n > 0 ? Math.round((points[n - 1].t - points[0].t) / DAY_MS) : 0;
if (n < LAB_TREND_MIN_VALUES) {
return { status: "insufficient", reason: "too_few_values", n, spanDays };
}
if (spanDays < LAB_TREND_MIN_SPAN_DAYS) {
return { status: "insufficient", reason: "span_too_short", n, spanDays };
}
const t0 = points[0].t;
const xs = points.map((p) => (p.t - t0) / DAY_MS / YEAR_DAYS);
const ys = points.map((p) => p.v);
const fit = linearFit(xs, ys);
if (!fit) {
return { status: "insufficient", reason: "span_too_short", n, spanDays };
}
const df = n - 2;
// Residual variance from the centred sums: SSR = Syy − Sxy² / Sxx.
const ssr = Math.max(0, fit.syy - (fit.sxy * fit.sxy) / fit.sxx);
const se = df > 0 ? Math.sqrt(ssr / df / fit.sxx) : Infinity;
const tCrit = criticalTTwoSided(df) ?? Infinity;
const half = tCrit * se;
const lo = fit.slope - half;
const hi = fit.slope + half;
const pValue =
se === 0
? fit.slope === 0
? 1
: 0
: twoSidedPFromT(Math.abs(fit.slope / se), df);
const xNow = (now.getTime() - t0) / DAY_MS / YEAR_DAYS;
const fittedNow = fit.intercept + fit.slope * xNow;
let projection: LabTrendProjection;
let exitDate: string | null = null;
let exitBound: "low" | "high" | null = null;
const hasBand = band.low != null || band.high != null;
if (!hasBand) projection = "no_band";
else if (!(lo > 0 || hi < 0)) projection = "no_clear_trend";
else if (
(band.low != null && fittedNow < band.low) ||
(band.high != null && fittedNow > band.high)
) {
projection = "already_outside";
} else {
const bound = fit.slope > 0 ? band.high : band.low;
if (bound == null) projection = "no_exit";
else {
const xCross = (bound - fit.intercept) / fit.slope;
const yearsAhead = xCross - xNow;
if (yearsAhead > LAB_PROJECTION_HORIZON_YEARS) {
projection = "beyond_horizon";
} else {
projection = "exits";
exitBound = fit.slope > 0 ? "high" : "low";
exitDate = dayOf(t0 + xCross * YEAR_DAYS * DAY_MS);
}
}
}
return {
status: "ok",
n,
firstAt: dayOf(points[0].t),
lastAt: dayOf(points[n - 1].t),
spanDays,
slopePerYear: round(fit.slope, 4),
slopeCi95: [round(lo, 4), round(hi, 4)],
pValue: round(pValue, 6),
fittedNow: round(fittedNow, 4),
asOf: dayOf(now.getTime()),
projection,
exitDate,
exitBound,
};
}
@@ -127,6 +127,15 @@ function deduplicateWinningRows<T extends WorkoutPickerRow>(rows: T[]): T[] {
*/
const BUCKET_SLOT_MS = 5 * 60 * 1000;
/**
* The cross-source duplicate slot a workout falls in. Exported for the one
* reader that has to look past the picked row: the strain engine, whose winner
* may carry no heart-rate series while its dropped twin does.
*/
export function workoutDuplicateSlotKey(row: WorkoutPickerRow): string {
return bucketKey(row);
}
function bucketKey(row: WorkoutPickerRow): string {
const slot = Math.floor(row.startedAt.getTime() / BUCKET_SLOT_MS);
return `${row.userId ?? ""}:${slot}:${row.sportType}`;
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import {
computeAssessmentTrends,
MEANINGFUL_CHANGE,
verdictForChange,
} from "../assessment-trend";
const row = (
id: string,
instrument: string,
totalScore: number,
day: string,
) => ({
id,
instrument,
totalScore,
takenAt: new Date(`${day}T09:00:00.000Z`),
});
describe("meaningful-change thresholds", () => {
it("are the published minimal important differences", () => {
expect(MEANINGFUL_CHANGE).toEqual({ PHQ9: 5, GAD7: 4, WHO5: 10 });
});
it("classify in the instrument's direction, inclusive at the threshold", () => {
expect(verdictForChange(-5, 5, false)).toBe("improved");
expect(verdictForChange(-4, 5, false)).toBe("within_threshold");
expect(verdictForChange(5, 5, false)).toBe("worsened");
expect(verdictForChange(12, 10, true)).toBe("improved");
expect(verdictForChange(-10, 10, true)).toBe("worsened");
});
});
describe("computeAssessmentTrends", () => {
it("compares the latest score with the previous and the first", () => {
const trends = computeAssessmentTrends([
row("c", "PHQ9", 9, "2026-06-01"),
row("a", "PHQ9", 16, "2026-01-10"),
row("b", "PHQ9", 12, "2026-03-15"),
row("g", "GAD7", 10, "2026-03-15"),
row("h", "GAD7", 13, "2026-06-01"),
row("w", "WHO5", 44, "2026-06-01"),
row("s", "SCI", 20, "2026-06-01"),
]);
expect(trends.map((t) => t.instrument)).toEqual(["PHQ9", "GAD7", "WHO5"]);
const [phq, gad, who] = trends;
expect(phq).toMatchObject({
n: 3,
threshold: 5,
changeFromPrevious: -3,
changeFromFirst: -7,
sincePrevious: "within_threshold",
sinceFirst: "improved",
first: { score: 16 },
latest: { score: 9 },
});
expect(gad).toMatchObject({
n: 2,
changeFromPrevious: 3,
sincePrevious: "within_threshold",
});
expect(who).toMatchObject({
n: 1,
previous: null,
changeFromPrevious: null,
sincePrevious: null,
sinceFirst: null,
});
});
it("orders same-day administrations by id", () => {
const [phq] = computeAssessmentTrends([
row("b", "PHQ9", 20, "2026-06-01"),
row("a", "PHQ9", 10, "2026-06-01"),
]);
expect(phq.latest.score).toBe(20);
expect(phq.sincePrevious).toBe("worsened");
});
});
+134
View File
@@ -0,0 +1,134 @@
/**
* v1.44 — screener change measured against the size studies treat as
* meaningful, instead of the last score on its own.
*
* A difference between two administrations is only worth naming when it is
* at least the instrument's minimal clinically important difference, which
* the primary literature puts at:
*
* - PHQ-9: 5 points (Löwe, Unützer, Callahan, Perkins & Kroenke 2004,
* Med Care 42(12):1194–1201).
* - GAD-7: 4 points (Toussaint, Hüsing, Gumz et al. 2020, J Affect Disord
* 265:395–401).
* - WHO-5: 10 points on the 0–100 percentage scale (Topp, Østergaard,
* Søndergaard & Bech 2015, Psychother Psychosom 84(3):167–176).
*
* A change below the threshold reads as "within the threshold", not as
* better or worse; a change at or beyond it reads in the instrument's own
* direction (lower is better on PHQ-9 / GAD-7, higher on WHO-5). This is a
* description of the person's own scores, never a diagnosis and never a
* treatment response; the SCI has no published threshold of this kind and
* gets no trend. Pure; the read lives in the assessments route.
*/
import {
INSTRUMENTS,
type InstrumentId,
} from "@/lib/mental-health/instruments";
export const MEANINGFUL_CHANGE: Readonly<
Partial<Record<InstrumentId, number>>
> = {
PHQ9: 5,
GAD7: 4,
WHO5: 10,
};
export const TREND_INSTRUMENTS = Object.keys(
MEANINGFUL_CHANGE,
) as InstrumentId[];
export type ChangeVerdict = "improved" | "worsened" | "within_threshold";
export interface ScorePoint {
score: number;
takenAt: string;
}
export interface AssessmentTrend {
instrument: InstrumentId;
/** Administrations of this instrument on record. */
n: number;
/** The meaningful-change threshold, in the instrument's reported points. */
threshold: number;
higherIsBetter: boolean;
first: ScorePoint;
previous: ScorePoint | null;
latest: ScorePoint;
/** latest − previous; null with one administration. */
changeFromPrevious: number | null;
/** latest − first; null with one administration. */
changeFromFirst: number | null;
sincePrevious: ChangeVerdict | null;
sinceFirst: ChangeVerdict | null;
}
/** Classify a signed change against the threshold in the instrument's direction. */
export function verdictForChange(
delta: number,
threshold: number,
higherIsBetter: boolean,
): ChangeVerdict {
if (Math.abs(delta) < threshold) return "within_threshold";
return delta > 0 === higherIsBetter ? "improved" : "worsened";
}
/**
* One trend per instrument that has a threshold and at least one
* administration, in the instrument order. Rows may arrive in any order;
* they are ordered by `takenAt`, then `id`.
*/
export function computeAssessmentTrends(
rows: ReadonlyArray<{
id: string;
instrument: string;
totalScore: number;
takenAt: Date;
}>,
): AssessmentTrend[] {
const out: AssessmentTrend[] = [];
for (const instrument of TREND_INSTRUMENTS) {
const threshold = MEANINGFUL_CHANGE[instrument] as number;
const series = rows
.filter(
(r) => r.instrument === instrument && Number.isFinite(r.totalScore),
)
.sort(
(a, b) =>
a.takenAt.getTime() - b.takenAt.getTime() || (a.id < b.id ? -1 : 1),
);
if (series.length === 0) continue;
const { higherIsBetter } = INSTRUMENTS[instrument];
const point = (r: (typeof series)[number]): ScorePoint => ({
score: r.totalScore,
takenAt: r.takenAt.toISOString(),
});
const latest = series[series.length - 1];
const previous = series.length > 1 ? series[series.length - 2] : null;
const first = series[0];
const changeFromPrevious = previous
? latest.totalScore - previous.totalScore
: null;
const changeFromFirst =
series.length > 1 ? latest.totalScore - first.totalScore : null;
out.push({
instrument,
n: series.length,
threshold,
higherIsBetter,
first: point(first),
previous: previous ? point(previous) : null,
latest: point(latest),
changeFromPrevious,
changeFromFirst,
sincePrevious:
changeFromPrevious === null
? null
: verdictForChange(changeFromPrevious, threshold, higherIsBetter),
sinceFirst:
changeFromFirst === null
? null
: verdictForChange(changeFromFirst, threshold, higherIsBetter),
});
}
return out;
}
@@ -60,6 +60,8 @@ import {
MOVEMENT_AWAY_CHANNEL_KEY,
MOVEMENT_DISTANCE_CHANNEL_KEY,
DISCOVERY_OUTCOMES,
EXTENDED_CHANNEL_KEYS,
NUTRIENT_CHANNEL_PREFIX,
FACTOR_CHANNEL_PREFIX,
} from "@/lib/insights/correlation-discovery";
import { DERIVED_METRIC_IDS } from "@/lib/insights/derived/registry";
@@ -197,6 +199,9 @@ describe("every surface id names a surface that exists", () => {
MOVEMENT_AWAY_CHANNEL_KEY,
MOVEMENT_DISTANCE_CHANNEL_KEY,
"LAB_DRAWS",
...EXTENDED_CHANNEL_KEYS,
// The fold target of every `NUTRIENT:<code>` channel.
"NUTRIENT",
]);
for (const id of localIds("correlation")) {
expect(channels.has(id), id).toBe(true);
@@ -209,6 +214,21 @@ describe("every surface id names a surface that exists", () => {
expect(correlationChannelSurfaceId(`${SYMPTOM_CHANNEL_PREFIX}abc`)).toBe(
"correlation:SYMPTOM_SEVERITY",
);
expect(correlationChannelSurfaceId(`${NUTRIENT_CHANNEL_PREFIX}zinc`)).toBe(
"correlation:NUTRIENT",
);
});
it("every extended channel has an owner or is reviewed as unowned", () => {
const unowned = new Set(["EXERCISE_MINUTES", "STAND_MINUTES"]);
for (const key of EXTENDED_CHANNEL_KEYS) {
if (unowned.has(key)) {
expect(surfaceModule(`correlation:${key}`), key).toBeUndefined();
} else {
expect(surfaceModule(`correlation:${key}`), key).toBeDefined();
}
}
expect(surfaceModule("correlation:NUTRIENT")).toBe("nutrients");
});
});
+21
View File
@@ -228,6 +228,24 @@ const STATIC_SURFACE_MODULE = {
// v1.43 — the Dawarich movement channels ride the environment module.
"correlation:MOVEMENT_AWAY": "environment",
"correlation:MOVEMENT_DISTANCE": "environment",
// v1.44 — the extended families. Intake rides the opt-in nutrient log (the
// `NUTRIENT:<code>` micronutrient channels fold onto `correlation:NUTRIENT`),
// alcohol days are read from mood entries' tag, and the two training
// channels from the workout record. State of Mind and mindfulness are
// self-reports about feeling, so they follow mood. The room climate rides
// the environment module like the weather. Exercise and stand minutes are
// ambient movement like steps and carry no owner.
"correlation:HYDRATION": "nutrients",
"correlation:CAFFEINE": "nutrients",
"correlation:NUTRIENT": "nutrients",
"correlation:ALCOHOL": "mood",
"correlation:WORKOUT_MINUTES": "workouts",
"correlation:TRAINING_LOAD": "workouts",
"correlation:MINDFUL_MINUTES": "mood",
"correlation:STATE_OF_MIND_VALENCE": "mood",
"correlation:INDOOR_CO2": "environment",
"correlation:INDOOR_TEMPERATURE": "environment",
"correlation:INDOOR_HUMIDITY": "environment",
// ── Day view sections (v1.42) ──
// The readings carry no owner here: each value follows its own type's
@@ -355,5 +373,8 @@ export function correlationChannelSurfaceId(channelKey: string): string {
// A person-defined symptom (`SYMPTOM:<id>`, v1.40) rides the illness
// module, like the symptom-burden channel it sits beside.
if (channelKey.startsWith("SYMPTOM:")) return "correlation:SYMPTOM_SEVERITY";
// One micronutrient's intake (`NUTRIENT:<code>`, v1.44) rides the nutrient
// log like water and caffeine.
if (channelKey.startsWith("NUTRIENT:")) return "correlation:NUTRIENT";
return `correlation:${channelKey}`;
}
+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" });
+13 -1
View File
@@ -22,6 +22,10 @@ import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-work
import { cached, caches } from "@/lib/cache/server-cache";
import { workoutSportTypeEnum } from "@/lib/validations/workout";
import { isoDateTime } from "@/lib/validations/datetime";
import {
readTrainingLoad,
type TrainingLoad,
} from "@/lib/insights/training-load";
function emptyQueryValueToUndefined(value: unknown): unknown {
if (value == null) return undefined;
@@ -113,6 +117,12 @@ export interface WorkoutsResult {
offset: number;
droppedDuplicates: number;
};
/**
* v1.44 — fitness, fatigue and form from the stored training impulse.
* Account-wide (no filter applies) and on the first page only, so a client
* paging through history does not recompute it per page.
*/
trainingLoad?: TrainingLoad;
}
export async function buildWorkoutsProjection(
@@ -257,5 +267,7 @@ export async function readWorkoutsListCached(
annotate,
);
return sliceWorkoutsProjection(projection, params.limit, params.offset);
const page = sliceWorkoutsProjection(projection, params.limit, params.offset);
if (params.offset !== 0) return page;
return { ...page, trainingLoad: await readTrainingLoad(userId) };
}