feat(insights): resolve direction verdicts, chart reference lines and mood patterns on the server

This commit is contained in:
Marc-André Bombeck committed 2026-10-15 23:39:11 +02:00
1 parent b91328d67e
commit 79f7eb199b
35 files changed
+2454 -50

No files matched your search

@@ -367,6 +367,18 @@ describe("GET /api/dashboard/summary", () => {
expect(bp?.allTimeCount).toBe(28);
expect(bp?.lastSeenAt).toBe(sixtyDaysAgo.toISOString());
expect(bp?.sparkline).toEqual([130, 132]);
// v1.43 (ios #116 item 3) — a 40-year-old's age band is 120–129: the
// sparkline's mean sits above it, so the rise is a setback.
const judged = bp as unknown as {
direction: unknown;
trendSentiment: string;
};
expect(judged.direction).toEqual({
direction: "up-bad",
targetPosition: "above",
basis: "profile_default",
});
expect(judged.trendSentiment).toBe("negative");
});
it("does NOT emit the BP tile when the account has zero readings ever (REG-11)", async () => {
+89 -1
View File
@@ -61,6 +61,13 @@ import type { MeasurementType } from "@/generated/prisma/client";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import {
resolveTrendVerdict,
trendSentimentOfTrend,
type TrendSentiment,
type TrendVerdict,
type TrendVerdictContext,
} from "@/lib/targets/trend-verdict";
import { type Locale } from "@/lib/i18n/config";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
@@ -185,6 +192,69 @@ interface MetricCard {
* at all.
*/
lastSeenAt: string | null;
/**
* v1.43 (ios #116 item 3) — which way a change of this metric counts for
* the person, against their own target or a profile default
* (`src/lib/targets/trend-verdict.ts`).
*/
direction?: TrendVerdict | null;
/** v1.43 — `direction` applied to this card's `trend`. */
trendSentiment?: TrendSentiment;
}
/**
* The verdict key behind each card: the measurement type it reads, or the
* derived key for the two cards that are not one.
*/
const VERDICT_KEY: Record<MetricKind, string> = {
weight: "WEIGHT",
bloodPressure: "BLOOD_PRESSURE_SYS",
pulse: "PULSE",
bodyFat: "BODY_FAT",
glucose: "BLOOD_GLUCOSE",
sleep: "SLEEP_DURATION",
steps: "ACTIVITY_STEPS",
totalBodyWater: "TOTAL_BODY_WATER",
boneMass: "BONE_MASS",
oxygenSaturation: "OXYGEN_SATURATION",
mood: "MOOD",
bmi: "BMI",
};
/**
* Lay the verdict over each card. The reference placed against the band is
* the mean of the card's own seven-day sparkline, else its latest value, the
* seven-day average the web tiles place. Sleep cards are in hours and the
* verdict reads minutes.
*/
function withVerdicts(
cards: MetricCard[],
ctx: Omit<TrendVerdictContext, "referenceValue">,
): MetricCard[] {
const referenceOf = (card: MetricCard | undefined): number | null => {
if (!card) return null;
const spark = card.sparkline.filter((v) => Number.isFinite(v));
return spark.length > 0
? spark.reduce((sum, v) => sum + v, 0) / spark.length
: card.latestValue;
};
// BMI is judged by the weight it is derived from (the target is a weight).
const weightReference = referenceOf(cards.find((c) => c.kind === "weight"));
return cards.map((card) => {
const reference = card.kind === "bmi" ? weightReference : referenceOf(card);
const direction = resolveTrendVerdict(VERDICT_KEY[card.kind], {
...ctx,
referenceValue:
reference !== null && card.kind === "sleep"
? reference * 60
: reference,
});
return {
...card,
direction,
trendSentiment: trendSentimentOfTrend(card.trend, direction),
};
});
}
/**
@@ -945,6 +1015,17 @@ async function buildDashboardSummary(
const t = getServerTranslator(ctx.locale).t;
// v1.43 — the record's profile and own targets, for the card verdicts.
const verdictProfile = await prisma.user.findUnique({
where: { id: userId },
select: {
dateOfBirth: true,
gender: true,
heightCm: true,
thresholdsJson: true,
},
});
annotate({ meta: timings });
return {
@@ -962,7 +1043,14 @@ async function buildDashboardSummary(
takenToday,
},
highlightInsight: null,
metrics,
metrics: withVerdicts(metrics, {
profile: {
dateOfBirth: verdictProfile?.dateOfBirth ?? null,
gender: verdictProfile?.gender ?? null,
heightCm: verdictProfile?.heightCm ?? null,
},
thresholdsJson: verdictProfile?.thresholdsJson ?? null,
}),
// v1.17.0 — sleep-debt + chronotype, server-authoritative. Same values
// the Sleep page's `/api/sleep/rhythm` read returns.
sleepRhythm,
@@ -533,6 +533,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--success)",
},
source: "WHO BMI",
direction: {
direction: "up-bad",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
...fourDaysInRange,
},
{
@@ -548,6 +554,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--info)",
},
source: "ESH 2023",
direction: {
direction: "hold",
targetPosition: "inside",
basis: "profile_default",
},
trendSentiment: "positive",
...fourDaysInRange,
},
{
@@ -563,6 +575,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--success)",
},
source: "ESH 2023",
direction: {
direction: "up-good",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
...fourDaysInRange,
},
{
@@ -578,6 +596,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--warning)",
},
source: "CDC/NCHS",
direction: {
direction: "up-good",
targetPosition: "below",
basis: "profile_default",
},
trendSentiment: "neutral",
daysInRange7d: 0,
daysLogged7d: 4,
daysInRange30d: 0,
@@ -600,6 +624,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--success)",
},
source: "AASM/SRS",
direction: {
direction: "up-good",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
...fourDaysInRange,
},
{
@@ -615,6 +645,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--success)",
},
source: "WHO",
direction: {
direction: "up-bad",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
...fourDaysInRange,
},
{
@@ -630,6 +666,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--warning)",
},
source: "ACE",
direction: {
direction: "hold",
targetPosition: "inside",
basis: "profile_default",
},
trendSentiment: "positive",
...fourDaysInRange,
},
{
@@ -645,6 +687,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--success)",
},
source: "Saint-Maurice JAMA 2020",
direction: {
direction: "up-good",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
...fourDaysInRange,
},
{
@@ -657,6 +705,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
range: { min: 90, max: 100 },
classification: null,
source: "7-day",
direction: {
direction: "up-good",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
details: {
medications: [
{
@@ -688,6 +742,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--success)",
},
source: "Mood entries",
direction: {
direction: "up-good",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
...fiveDaysInRange,
},
{
@@ -703,6 +763,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--success)",
},
source: "Mood entries",
direction: {
direction: "neutral",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
...fiveDaysInRange,
},
{
@@ -718,6 +784,12 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--success)",
},
source: "ADA 2024 / DDG",
direction: {
direction: "neutral",
targetPosition: null,
basis: "metric_default",
},
trendSentiment: "neutral",
daysInRange7d: 3,
daysLogged7d: 3,
daysInRange30d: 3,
@@ -536,6 +536,117 @@ describe("GET /api/measurements/series", () => {
);
});
describe("GET /api/measurements/series — resolved judgements (v1.43, ios #116)", () => {
function bpDays(values: Array<[string, number]>) {
vi.mocked(prisma.measurement.findMany).mockImplementation(((
args: unknown,
) => {
const a = args as { where: { type: string } };
if (a.where.type !== "BLOOD_PRESSURE_SYS") return Promise.resolve([]);
return Promise.resolve(
values.map(([day, value], i) => ({
id: `s${i}`,
value,
measuredAt: new Date(`${day}T08:00:00.000Z`),
})),
);
}) as never);
}
it("judges the window's change against the person's own target and draws its lines", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mg/dL",
dateOfBirth: null,
gender: null,
heightCm: null,
locale: "en",
thresholdsJson: { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } },
} as never);
bpDays([
["2026-05-01", 128],
["2026-05-02", 130],
["2026-05-03", 134],
]);
const res = await GET(req("kind=bloodPressure&days=30"));
const body = (await res.json()) as {
data: {
direction: unknown;
change: unknown;
referenceLines: Array<Record<string, unknown>>;
};
};
expect(body.data.direction).toEqual({
direction: "up-bad",
targetPosition: "above",
basis: "personal_target",
});
expect(body.data.change).toEqual({
from: 128,
to: 134,
delta: 6,
sentiment: "negative",
});
expect(
body.data.referenceLines.map((l) => [
l.appliesTo,
l.kind,
l.value,
l.source,
]),
).toEqual([
["value", "lower", 115, "personal"],
["value", "upper", 125, "personal"],
["secondary", "upper", 90, "clinical"],
]);
expect(body.data.referenceLines[0].label).toBe("Your target, lower limit");
});
it("calls the same rise progress for someone below their target", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mg/dL",
dateOfBirth: null,
gender: null,
heightCm: null,
locale: "en",
thresholdsJson: { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } },
} as never);
bpDays([
["2026-05-01", 100],
["2026-05-03", 106],
]);
const res = await GET(req("kind=bloodPressure&days=30"));
const body = (await res.json()) as {
data: { change: { sentiment: string } };
};
expect(body.data.change.sentiment).toBe("positive");
});
it("has no change under two days and the clinical glucose lines in mmol/L", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mmol/L",
locale: "de",
thresholdsJson: null,
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ id: "g1", value: 100, measuredAt: new Date("2026-05-01T08:00:00Z") },
] as never);
const res = await GET(req("kind=glucose&days=30"));
const body = (await res.json()) as {
data: {
change: unknown;
direction: { direction: string };
referenceLines: Array<{ value: number; label: string }>;
};
};
expect(body.data.change).toBeNull();
expect(body.data.referenceLines.map((l) => l.value)).toEqual([3.9, 10]);
expect(body.data.referenceLines[0].label).toBe("Klinische Untergrenze");
});
});
describe("GET /api/measurements/series — dense-kind day-bucketing (v1.28.25)", () => {
it("day-buckets glucose in SQL beyond the 90-day raw window", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+68 -1
View File
@@ -7,6 +7,12 @@
*
* The `kind=bloodPressure` case pairs systolic + diastolic by
* `measuredAt` so iOS can render the dual-line chart with one fetch.
*
* v1.43 (ios #116 items 3 and 4) — the answer also carries what a client
* would otherwise decide itself: `direction` (which way a change of this
* metric counts for this person), `change` (first and last day value of the
* window, judged by that direction) and `referenceLines` (the person's own
* target lines, else the clinical ones, in the unit of the points).
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -37,6 +43,15 @@ import { seriesRowsFrom } from "@/lib/measurements/series-canonical";
import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql";
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import { resolveTrendVerdict } from "@/lib/targets/trend-verdict";
import {
computeSeriesChange,
seriesDayValues,
seriesReferenceValue,
} from "@/lib/measurements/series-change";
import { resolveSeriesReferenceLines } from "@/lib/measurements/series-reference-lines";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
/**
* v1.11.4 — sleep is read row-per-stage (not from the day rollup), so its
@@ -199,7 +214,7 @@ function stdDev(values: number[]): number {
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "measurements");
const { user, actor } = await requireRecordAuth("read", "measurements");
const rawQuery = Object.fromEntries(request.nextUrl.searchParams);
const parsed = querySchema.safeParse(rawQuery);
@@ -814,6 +829,55 @@ export const GET = apiHandler(async (request: NextRequest) => {
const summary = dataPoints.length > 0 ? summarize(dataPoints) : null;
const values = dataPoints.map((p) => p.value);
// v1.43 — the resolved judgements. The profile and targets are the
// RECORD's (the chart is theirs); the labels are in the language of the
// account reading it.
const [record, reader, seriesTz] = await Promise.all([
prisma.user.findUnique({
where: { id: user.id },
select: {
dateOfBirth: true,
gender: true,
heightCm: true,
thresholdsJson: true,
glucoseUnit: true,
locale: true,
},
}),
actor.id === user.id
? null
: prisma.user.findUnique({
where: { id: actor.id },
select: { locale: true },
}),
resolveUserTimezone(user.id),
]);
const rangeProfile = {
dateOfBirth: record?.dateOfBirth ?? null,
gender: record?.gender ?? null,
heightCm: record?.heightCm ?? null,
};
const verdictType = KIND_TO_TYPE[kind];
const dayValues = seriesDayValues(verdictType, points, seriesTz);
const reference = seriesReferenceValue(dayValues);
const direction = resolveTrendVerdict(verdictType, {
profile: rangeProfile,
thresholdsJson: record?.thresholdsJson ?? null,
// The verdict reads stored units: sleep is served in hours here and
// stored in minutes. Glucose has no band of its own on this chart.
referenceValue:
reference !== null && kind === "sleep" ? reference * 60 : reference,
});
const translator = getServerTranslator(
await resolveJobLocale((reader ?? record)?.locale ?? null),
);
const referenceLines = resolveSeriesReferenceLines(kind, {
profile: rangeProfile,
thresholdsJson: record?.thresholdsJson ?? null,
glucoseUnit: resolveGlucoseUnit(record?.glucoseUnit ?? null),
t: (key) => translator.t(key),
});
annotate({
action: { name: "measurements.series" },
meta: { kind, days, count: points.length },
@@ -823,6 +887,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
kind,
unit,
points,
direction,
change: computeSeriesChange(dayValues, direction),
referenceLines,
stats:
statsOverride ??
(summary
@@ -41,6 +41,22 @@ const AGGREGATE = {
tags: [],
stability: year,
stabilityByWindow: { 30: month, 90: null, 180: year, 365: year },
patternsByWindow: {
30: [],
90: [],
180: [],
365: [
{
id: "weekend",
kind: "weekend",
strength: "strong",
direction: "higher",
values: { subject: 4.2, comparison: 3.1, delta: 1.1 },
sampleSize: { subject: 100, comparison: 250 },
qValue: 0.001,
},
],
},
};
const call = (query = "") =>
@@ -78,6 +94,23 @@ describe("GET /api/mood/insights — stability per period", () => {
expect(body.data.stabilityWindowDays).toBe(90);
});
it("serves the patterns of the same window, without the cache's internals", async () => {
const year = await (await call()).json();
expect(year.data.patterns).toEqual([
{
id: "weekend",
kind: "weekend",
strength: "strong",
direction: "higher",
values: { subject: 4.2, comparison: 3.1, delta: 1.1 },
sampleSize: { subject: 100, comparison: 250 },
},
]);
expect(year.data).not.toHaveProperty("patternsByWindow");
const month = await (await call("?days=30")).json();
expect(month.data.patterns).toEqual([]);
});
it("refuses a window it does not offer", async () => {
const res = await call("?days=45");
expect(res.status).toBe(422);
+10 -2
View File
@@ -12,6 +12,8 @@ import {
type MoodStabilityWindow,
} from "@/lib/insights/mood-aggregates";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveJobLocale } from "@/lib/i18n/job-locale";
export const dynamic = "force-dynamic";
@@ -33,7 +35,8 @@ export const dynamic = "force-dynamic";
*/
/**
* `?days=N` cuts `stability` to the trailing N days, so a period picker shows
* the steadiness of the period it shows. Without it the score covers the
* the steadiness of the period it shows. Since v1.43 it cuts `patterns` the
* same way. Without it the score covers the
* whole year, as it always has; `stabilityWindowDays` names the window the
* served score covers either way.
*/
@@ -75,5 +78,10 @@ export const GET = apiHandler(async (request: Request) => {
},
});
return apiSuccess(moodInsightsWire(result, stabilityWindowDays));
// v1.43 — `patterns` follows the same `?days` window, and a catalogue
// tag in a pattern is named in the reader's language.
const translator = getServerTranslator(await resolveJobLocale(user.locale));
return apiSuccess(
moodInsightsWire(result, stabilityWindowDays, (key) => translator.t(key)),
);
});