Merge v1.25.1: deep data/cross-cut (cadence-aware alert compliance, lab-unit guard, signals in briefing, biomarker fallback, cleanups)

This commit is contained in:
Marc-André Bombeck committed 2026-06-29 00:38:45 +02:00
commit 2ec4828c1f
21 files changed
+509 -80

No files matched your search

@@ -164,4 +164,68 @@ describe("GET /api/insights/cards", () => {
expect(body.data[0].provider).toBe("anthropic");
expect(body.data.some((c) => c.severity === "alert")).toBe(true);
});
it("does NOT flag a fully-adherent weekly injectable as low compliance (#214 regression)", async () => {
// Pre-fix this path used a naive `schedules.length × 7` denominator, so a
// weekly Monday injectable with its one dose taken computed ~14% and fired
// a false "Low compliance" warning. The cadence-aware engine counts only
// the Monday in the window → 100%, no alert.
vi.useFakeTimers();
// Pin NOW to a Wednesday so the trailing 7-day window holds exactly one
// Monday (the prior 2025-01-13).
vi.setSystemTime(new Date("2025-01-15T12:00:00Z"));
try {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([
{
id: "med-weekly",
name: "Weekly injectable",
dose: null,
active: true,
asNeeded: false,
oneShot: false,
startsOn: null,
endsOn: null,
createdAt: new Date("2024-06-01T00:00:00Z"),
scheduleRevisions: [],
pauseEras: [],
schedules: [
{
id: "sched-mon",
windowStart: "08:00",
windowEnd: "10:00",
daysOfWeek: "1",
timesOfDay: ["08:30"],
reminderGraceMinutes: null,
rrule: "FREQ=WEEKLY;BYDAY=MO",
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
},
],
},
] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
{
medicationId: "med-weekly",
scheduledFor: new Date("2025-01-13T08:30:00Z"),
takenAt: new Date("2025-01-13T08:35:00Z"),
skipped: false,
},
] as never);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: Array<{ title: string }>;
};
expect(body.data.some((c) => c.title.startsWith("Low compliance"))).toBe(
false,
);
} finally {
vi.useRealTimers();
}
});
});
+50 -20
View File
@@ -20,6 +20,12 @@ import {
type HealthAlert,
} from "@/lib/analytics/classifications";
import { pairByTimestamp } from "@/lib/analytics/correlations";
import {
buildComplianceMedicationContext,
calculateCompliance,
lastNonSkippedTakenAt,
SCHEDULE_COMPLIANCE_SELECT,
} from "@/lib/analytics/compliance";
import type { MeasurementType } from "@/generated/prisma/client";
type Severity = "alert" | "caution" | "info" | "good";
@@ -86,11 +92,17 @@ export const GET = apiHandler(async () => {
select: { type: true, value: true, measuredAt: true },
}),
prisma.medication.findMany({
where: { userId: user.id, active: true },
// v1.16.11 — as-needed (PRN) medications never surface a compliance
// rate (no expected doses).
where: { userId: user.id, active: true, asNeeded: false },
include: {
schedules: true,
// Schedules through the shared compliance select so the configured
// per-dose windows reach the engine like every other surface.
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
// v1.16.3 — archived schedule eras for era-aware expected counts.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
// v1.25 H-MED1 — pause eras so paused days drop out of the denominator.
pauseEras: { select: { pausedAt: true, resumedAt: true } },
},
}),
]);
@@ -135,7 +147,12 @@ export const GET = apiHandler(async () => {
}
}
// Lightweight medication compliance for alert input.
// Medication compliance for alert input — routed through the canonical
// cadence-aware engine (`calculateCompliance`), the same single source of
// truth the comprehensive insights, dashboard pillar, Coach snapshot and
// doctor report use. The naive `schedules.length × days` denominator was a
// #214 regression that falsely flagged weekly injectables and paused meds
// as low-adherence on this user-facing alert path.
const medicationCompliance: Array<{
name: string;
compliance7: number;
@@ -150,8 +167,11 @@ export const GET = apiHandler(async () => {
// v1.7.0 sync — exclude tombstoned rows.
deletedAt: null,
medicationId: { in: activeMeds.map((m) => m.id) },
scheduledFor: { gte: new Date(Date.now() - 30 * 86_400_000) },
// 90-day window: the rolling-cadence gap-walk re-anchors on prior
// intakes outside the 30-day rate window.
scheduledFor: { gte: ninetyDaysAgo },
},
orderBy: { scheduledFor: "desc" },
select: {
medicationId: true,
takenAt: true,
@@ -165,26 +185,36 @@ export const GET = apiHandler(async () => {
arr.push(e);
eventsByMed.set(e.medicationId, arr);
}
const cutoff7 = Date.now() - 7 * 86_400_000;
const tz = user.timezone || "Europe/Berlin";
for (const med of activeMeds) {
const events = eventsByMed.get(med.id) ?? [];
const takenLast7 = events.filter(
(e) =>
e.takenAt !== null &&
!e.skipped &&
e.scheduledFor.getTime() >= cutoff7,
).length;
const taken30 = events.filter(
(e) => e.takenAt !== null && !e.skipped,
).length;
const expected7 = med.schedules.length * 7;
const expected30 = med.schedules.length * 30;
const mapped = events.map((e) => ({
takenAt: e.takenAt,
skipped: e.skipped,
scheduledFor: e.scheduledFor,
}));
// v1.7.0 SB-SCHED-2 — engine-routed denominator.
const medicationContext = buildComplianceMedicationContext(
med,
lastNonSkippedTakenAt(mapped),
tz,
);
const c7 = calculateCompliance(mapped, med.schedules, 7, med.createdAt, {
medicationContext,
});
const c30 = calculateCompliance(
mapped,
med.schedules,
30,
med.createdAt,
{
medicationContext,
},
);
medicationCompliance.push({
name: med.name,
compliance7:
expected7 > 0 ? Math.round((takenLast7 / expected7) * 100) : 0,
compliance30:
expected30 > 0 ? Math.round((taken30 / expected30) * 100) : 0,
compliance7: c7.rate,
compliance30: c30.rate,
});
}
}
+2 -10
View File
@@ -15,23 +15,15 @@ import { useTranslations } from "@/lib/i18n/context";
* The copy lives under the `selfDescription` i18n namespace so every locale
* carries the same set; pick the note that fits the surface:
*
* - `intendedPurpose` — the canonical "what this is / is not, not a medical
* device" line. Reuse verbatim; do not paraphrase per surface.
* - `coach` — the Coach standing line (works from your data, not a doctor).
* - `emergency` — the red-flag interstitial note (contact emergency services).
* - `dataPosture` — self-hosted, data stays on your instance, BYOK / local AI.
*
* The acknowledged onboarding disclaimer (`onboarding.disclaimer.*`) and the
* legal medical-device boundary on the public privacy page remain the standing
* floor; these notes are the gentle, in-context layer above it.
* floor; this note is the gentle, in-context layer above it.
*/
export type DisclaimerVariant =
"intendedPurpose" | "coach" | "emergency" | "dataPosture";
export type DisclaimerVariant = "dataPosture";
const VARIANT_KEY: Record<DisclaimerVariant, string> = {
intendedPurpose: "selfDescription.intendedPurpose",
coach: "selfDescription.coach",
emergency: "selfDescription.emergency",
dataPosture: "selfDescription.dataPosture",
};
+54 -20
View File
@@ -111,7 +111,11 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
const [lowerInput, setLowerInput] = useState("");
const [upperInput, setUpperInput] = useState("");
const { data: marker, isError: markerError } = useQuery({
const {
data: marker,
isError: markerError,
refetch: refetchMarker,
} = useQuery({
queryKey: queryKeys.biomarkerDetail(biomarkerId),
queryFn: () => apiGet<BiomarkerDto>(`/api/biomarkers/${biomarkerId}`),
});
@@ -125,6 +129,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
data: list,
isLoading,
isError: listError,
refetch: refetchList,
} = useInfiniteQuery({
queryKey: queryKeys.labResultsInfinite({ biomarkerId, sortDir: "desc" }),
initialPageParam: 0,
@@ -239,11 +244,50 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
onError: () => toast.error(t("labs.biomarker.targetRange.saveError")),
});
// v1.25.1 — the per-marker description leads the page, mirroring the metric
// sub-pages' explainer caption. Resolve the catalog slug from the marker name
// and fall back to the user's own `context`, then to a generic explainer so a
// catalog-less custom marker never renders an empty description block (parity
// with the insights metric pages, which always carry one).
const catalogSlug = marker?.name
? slugByName.get(marker.name.trim().toLowerCase())
: undefined;
const description = catalogSlug
? t(`labs.catalog.desc.${catalogSlug}`)
: marker?.context?.trim()
? marker.context.trim()
: t("labs.detail.genericDescription");
// v1.25.1 (H2-M2) — the error state keeps the heading + description and adds a
// retry, mirroring the insights `SubPageShell` recovery affordance rather than
// collapsing to a bare line with no context and no way back.
if (markerError || listError) {
return (
<p className="text-destructive py-8 text-center text-sm">
{t("labs.loadError")}
</p>
<div className="space-y-6">
<header className="space-y-1.5">
<h1 className="text-2xl font-bold tracking-tight">
{marker?.name ?? t("labs.detail.title")}
</h1>
<p className="text-muted-foreground text-sm leading-relaxed">
{description}
</p>
</header>
<Card>
<CardContent className="flex flex-col items-center gap-3 py-8 text-center">
<p className="text-destructive text-sm">{t("labs.loadError")}</p>
<Button
variant="outline"
size="sm"
onClick={() => {
if (markerError) void refetchMarker();
if (listError) void refetchList();
}}
>
{t("common.retry")}
</Button>
</CardContent>
</Card>
</div>
);
}
@@ -330,22 +374,12 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
</div>
{/* v1.25.1 — the static description leads the page, directly beneath the
heading, mirroring the metric sub-pages' explainer caption. Resolves
the catalog slug from the marker name, falling back to the user's own
`context`. */}
{(() => {
const slug = marker?.name
? slugByName.get(marker.name.trim().toLowerCase())
: undefined;
const description = slug
? t(`labs.catalog.desc.${slug}`)
: (marker?.context ?? null);
return description ? (
<p className="text-muted-foreground text-sm leading-relaxed">
{description}
</p>
) : null;
})()}
heading, mirroring the metric sub-pages' explainer caption. Computed
once above (catalog desc → user `context` → generic fallback) so a
catalog-less marker still carries a description. */}
<p className="text-muted-foreground text-sm leading-relaxed">
{description}
</p>
{isLoading ? (
<Card>
@@ -703,6 +703,7 @@ export function MedicationDetailTabs({
label={t("medications.detail.erweitert.group.externalApi")}
dataSlot="api-group-external-api"
collapsible
defaultOpen={false}
>
<div className="py-3">
<ApiTokensRow
@@ -727,6 +728,7 @@ export function MedicationDetailTabs({
label={t("medications.detail.erweitert.group.lifecycle")}
dataSlot="erweitert-group-lifecycle"
collapsible
defaultOpen={false}
>
<div className="py-3">
<LifecycleManageBody
@@ -301,7 +301,10 @@ export function MentalWellbeing() {
{result.crisis && (
<Card
className="border-amber-400/50"
// v1.25.1 — calm destructive-subtle border instead of the amber
// tint v1.25 retired; the role/aria-live carry the urgency, the
// tone stays consistent with the rest of the app.
className="border-destructive/40"
role="alert"
aria-live="assertive"
>
+23
View File
@@ -153,6 +153,29 @@ function authoritativeValues(
push(w.latest.daysAgo);
}
}
// v1.25.1 — clinical-depth aggregate blocks (grip / waist / pain) wired
// into the briefing. Admit their pre-computed figures so the prose may cite
// them, same posture as the glucose / labs / workout blocks above.
const grip = features.gripStrength;
if (grip) {
push(grip.latest);
push(grip.avg30);
push(grip.slope30);
}
const waist = features.waist;
if (waist) {
push(waist.latest);
push(waist.avg30);
push(waist.slope30);
push(waist.whtrLatest);
}
const pain = features.pain;
if (pain) {
push(pain.latest);
push(pain.avg7);
push(pain.avg30);
push(pain.slope30);
}
}
return values;
@@ -0,0 +1,98 @@
/**
* v1.25.1 — commit-path unit-integrity tests.
*
* Committing a numeric observation against an EXISTING biomarker must not
* silently rewrite the document's stated unit with the catalog's. When the
* units disagree the fact is rejected (fail closed) so the user reconciles on
* the review screen, exactly like the `unitRequired` guard — never a corrupt
* "6.1 mmol/L written as 6.1 mg/dL" reading.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
labResult: { create: vi.fn() },
},
}));
vi.mock("@/lib/labs/biomarker-store", () => ({
resolveOrMintBiomarker: vi.fn(),
}));
vi.mock("@/lib/documents/store", () => ({
decryptFactData: vi.fn(),
}));
import { commitApprovedFact, FactCommitError } from "@/lib/documents/commit";
import { prisma } from "@/lib/db";
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
import { decryptFactData } from "@/lib/documents/store";
import type { ExtractedFact } from "@/generated/prisma/client";
function observationFact(): ExtractedFact {
return {
id: "fact-1",
factType: "OBSERVATION",
dataEncrypted: new Uint8Array(),
} as unknown as ExtractedFact;
}
beforeEach(() => {
vi.resetAllMocks();
});
describe("commitApprovedFact — OBSERVATION unit integrity", () => {
it("rejects a numeric observation whose stated unit differs from an existing marker's unit", async () => {
vi.mocked(decryptFactData).mockReturnValue({
label: "Glucose",
value: 6.1,
unit: "mmol/L",
referenceLow: null,
referenceHigh: null,
effectiveDate: "2025-01-01",
} as never);
// Existing marker stored in mg/dL — the resolver returns it as-is.
vi.mocked(resolveOrMintBiomarker).mockResolvedValue({
id: "bm-1",
name: "Glucose",
unit: "mg/dL",
lowerBound: 70,
upperBound: 100,
panel: null,
});
await expect(
commitApprovedFact("user-1", observationFact()),
).rejects.toMatchObject({ code: "observation.unitMismatch" });
await expect(
commitApprovedFact("user-1", observationFact()),
).rejects.toBeInstanceOf(FactCommitError);
expect(prisma.labResult.create).not.toHaveBeenCalled();
});
it("commits when the stated unit matches the marker's unit (case/space-insensitive)", async () => {
vi.mocked(decryptFactData).mockReturnValue({
label: "Glucose",
value: 95,
unit: " mg/DL ",
referenceLow: null,
referenceHigh: null,
effectiveDate: "2025-01-01",
} as never);
vi.mocked(resolveOrMintBiomarker).mockResolvedValue({
id: "bm-1",
name: "Glucose",
unit: "mg/dL",
lowerBound: 70,
upperBound: 100,
panel: null,
});
vi.mocked(prisma.labResult.create).mockResolvedValue({
id: "lr-1",
} as never);
const ref = await commitApprovedFact("user-1", observationFact());
expect(ref).toEqual({ recordType: "labResult", recordId: "lr-1" });
expect(prisma.labResult.create).toHaveBeenCalledOnce();
});
});
+17
View File
@@ -71,6 +71,23 @@ async function commitObservation(
referenceHigh: isQualitative ? null : data.referenceHigh,
panel: null,
});
// Fail closed on a unit mismatch against an EXISTING marker. A freshly
// minted marker adopts the document's unit, so this only bites when the
// stated unit disagrees with the catalog's authoritative one (e.g. a
// document states "6.1 mmol/L" against a marker stored in mg/dL). Writing
// the stated number under the catalog's unit would corrupt the reading AND
// break the "transcribe verbatim" contract — so reject the fact and let the
// user reconcile on the review screen, exactly like the `unitRequired` gate.
if (!isQualitative) {
const stated = (data.unit as string).trim().toLowerCase();
const target = biomarker.unit.trim().toLowerCase();
if (stated !== target) {
throw new FactCommitError(
"observation.unitMismatch",
`The stated unit (${(data.unit as string).trim()}) does not match the saved unit for ${biomarker.name} (${biomarker.unit || "none"}). Reconcile the unit before saving.`,
);
}
}
const created = await prisma.labResult.create({
data: {
userId,
@@ -270,6 +270,51 @@ describe("extractFeatures — v1.22 glucose aggregate block", () => {
});
});
describe("extractFeatures — v1.25.1 clinical-depth aggregate blocks", () => {
it("emits grip, waist (with WHtR) and pain blocks from their readings", async () => {
prismaMock.measurement.findMany.mockResolvedValue([
measurementRow("GRIP_STRENGTH", 42, 1),
measurementRow("GRIP_STRENGTH", 40, 20),
measurementRow("WAIST_CIRCUMFERENCE", 90, 2),
measurementRow("WAIST_CIRCUMFERENCE", 92, 25),
measurementRow("PAIN_NRS", 4, 1),
measurementRow("PAIN_NRS", 2, 10),
]);
const f = await extractFeatures("user-1", false);
expect(f.gripStrength).toBeDefined();
expect(f.gripStrength?.latest).toBe(42);
expect(f.waist).toBeDefined();
expect(f.waist?.latest).toBe(90);
// heightCm is 180 in the default user mock → WHtR = 90 / 180 = 0.5.
expect(f.waist?.whtrLatest).toBe(0.5);
expect(f.pain).toBeDefined();
expect(f.pain?.latest).toBe(4);
});
it("omits the clinical-depth blocks when there are no readings", async () => {
const f = await extractFeatures("user-1", false);
expect(f.gripStrength).toBeUndefined();
expect(f.waist).toBeUndefined();
expect(f.pain).toBeUndefined();
});
it("leaves WHtR null when height is unknown", async () => {
prismaMock.user.findUnique.mockResolvedValue({
heightCm: null,
dateOfBirth: new Date("1980-01-01"),
gender: "MALE",
});
prismaMock.measurement.findMany.mockResolvedValue([
measurementRow("WAIST_CIRCUMFERENCE", 90, 2),
]);
const f = await extractFeatures("user-1", false);
expect(f.waist?.whtrLatest).toBeNull();
});
});
describe("extractFeatures — v1.22 labs briefing block", () => {
it("surfaces an abnormal (out-of-range) biomarker", async () => {
prismaMock.labResult.findMany.mockResolvedValue([
+84
View File
@@ -97,6 +97,45 @@ export interface AggregatedFeatures {
slope30: number | null;
coverage: DataCoverage;
};
/**
* v1.25.1 — grip-strength aggregate (kg). A clinical-depth signal captured
* and shown on its own detail page with EWGSOP2 sarcopenia bands; this is its
* present-and-trend block so the daily briefing can narrate the trajectory.
* The sex-aware floor stays at the display edge (`norms.ts`); the briefing
* reads the trend. Omitted when no grip readings exist. PHQ-9/GAD-7 stay
* excluded — only physical signals reach the narrative.
*/
gripStrength?: {
latest: number | null;
avg30: number | null;
slope30: number | null;
coverage: DataCoverage;
};
/**
* v1.25.1 — waist aggregate. `latest`/`avg30` are the circumference (cm);
* `whtrLatest` is the waist-to-height ratio (NICE keeps it < 0.5), computed
* from the freshest circumference and the user's height. Omitted when no
* waist readings exist.
*/
waist?: {
latest: number | null;
avg30: number | null;
slope30: number | null;
whtrLatest: number | null;
coverage: DataCoverage;
};
/**
* v1.25.1 — pain aggregate (0–10 NRS, lower-better). Present-and-trend block
* so the briefing can flag a sustained or rising pain burden. Omitted when no
* pain readings exist.
*/
pain?: {
latest: number | null;
avg7: number | null;
avg30: number | null;
slope30: number | null;
coverage: DataCoverage;
};
mood?: {
scale: string;
avg7: number | null;
@@ -1292,6 +1331,51 @@ export async function extractFeatures(
};
}
// Grip strength (kg) — clinical-depth signal; the briefing narrates the
// trajectory, the sex-aware EWGSOP2 floor stays at the display edge.
const gripData = byType("GRIP_STRENGTH");
if (gripData.length > 0) {
const summary = summarize(toDataPoints(gripData));
features.gripStrength = {
latest: summary.latest,
avg30: summary.avg30,
slope30: summary.slope30?.slope ?? null,
coverage: computeCoverage(gripData, now),
};
}
// Waist circumference (cm) + waist-to-height ratio. WHtR is computed from the
// freshest circumference and the user's height (the same canonical derivation
// the detail page uses); omitted when height is unknown.
const waistData = byType("WAIST_CIRCUMFERENCE");
if (waistData.length > 0) {
const summary = summarize(toDataPoints(waistData));
const whtrLatest =
user?.heightCm && summary.latest
? Math.round((summary.latest / user.heightCm) * 100) / 100
: null;
features.waist = {
latest: summary.latest,
avg30: summary.avg30,
slope30: summary.slope30?.slope ?? null,
whtrLatest,
coverage: computeCoverage(waistData, now),
};
}
// Pain (0–10 NRS, lower-better) — surface a sustained or rising pain burden.
const painData = byType("PAIN_NRS");
if (painData.length > 0) {
const summary = summarize(toDataPoints(painData));
features.pain = {
latest: summary.latest,
avg7: summary.avg7,
avg30: summary.avg30,
slope30: summary.slope30?.slope ?? null,
coverage: computeCoverage(painData, now),
};
}
// Sleep Duration
//
// SLEEP_DURATION is stored ONE ROW PER STAGE per night, so summarising the
@@ -25,6 +25,7 @@ import {
import { deriveMetricStatusRegistry } from "@/lib/signals/adapters/metric-status";
import { deriveMeasurementLoinc } from "@/lib/signals/adapters/fhir";
import { deriveBucketedTypes } from "@/lib/signals/adapters/correlation";
import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog";
import expectedMeasurementLoinc from "./fixtures/expected-measurement-loinc.json";
import expectedBucketedTypes from "./fixtures/expected-bucketed-types.json";
@@ -48,6 +49,22 @@ describe("signal registry — structural sanity", () => {
}
});
it("every biomarker signal's source key resolves to a real catalog slug", () => {
// The declared registry↔labs bridge: a `kind: "biomarker"` signal's
// `source.biomarkerKey` must name an actual `BIOMARKER_CATALOG` slug. The
// length-only check above would let a typo (`"lpa"` vs `"lp-a"`) ride
// through; this catches biomarker-slug drift the moment it lands.
const catalogSlugs = new Set(BIOMARKER_CATALOG.map((b) => b.slug));
for (const signal of allSignals()) {
if (signal.kind === "biomarker") {
expect(
catalogSlugs.has(signal.source.biomarkerKey),
`biomarker signal "${signal.key}" → unknown catalog slug "${signal.source.biomarkerKey}"`,
).toBe(true);
}
}
});
it("every Coach-snapshot signal is also MCP-readable", () => {
// `mcp` and `coachSnapshot` are INDEPENDENT facets: a Coach-scoped signal
// is always reachable over MCP (the Coach `get_metric_series` path), but a