mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
Marc reported the BD-Zielbereich tile pinned at exactly 50/50/50 for
7T, 30T, and the headline ("total") on his real production data — the
fourth attempt at this surface (v1.4.15 → v1.4.16 → v1.4.18 → v1.4.19).
Root cause: the analytics route routed `bpInTargetPct` (the headline)
through `windows.last30Days?.pct` — a literal copy of the `30T`
sub-value. Since `computeBpInTargetWindows` only returned 7d + 30d
windows, the headline could never legitimately differ from `30T`. For
Marc's real data (572 paired readings since 2022, recent 30d = 50 %,
all-time ≈ 11 %) the tile read 50 % / 50 % / 50 % and looked
algorithmically pinned — exactly the symptom he reported.
The previous three passes treated this as either a predicate bug
(v1.4.16 ceiling-vs-narrow-band, real and merged) or a missing
`avg7`/`avg30` wiring (v1.4.18, also real and merged). Both fixes
landed correctly, but neither addressed the headline duplication.
Fix:
- `computeBpInTargetWindows` now returns a third `allTime` window
that aggregates EVERY paired reading in the input series.
- The analytics route fetches all paired BP rows (no 30-day cutoff)
and routes the headline through `windows.allTime?.pct`. The route
only re-pairs once internally so the cost is paid once per request.
- The 7T / 30T sub-values are unchanged (still windowed slices of
the same input).
Failing test reproduces the bug with Marc's data shape (`allTime` MUST
not equal `last30Days` once older history is present); fix turns it
green. Integration test seeds 40 paired readings against real Postgres
and asserts all three windows compute independently.
Hand-counted against Marc's prod data:
- 7T: 50 % (1/2) — matches what he sees, real coincidence
- 30T: 50 % (5/10) — matches what he sees, real coincidence
- Headline: ~11 % (62/572) — was 50 %, now correct
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
115 lines
4.2 KiB
TypeScript
115 lines
4.2 KiB
TypeScript
import { prisma } from "@/lib/db";
|
|
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import { apiSuccess } from "@/lib/api-response";
|
|
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
|
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
|
import { computeBpInTargetWindows } from "@/lib/analytics/bp-in-target";
|
|
import type { MeasurementType } from "@/generated/prisma/client";
|
|
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
export const GET = apiHandler(async () => {
|
|
const { user } = await requireAuth();
|
|
annotate({ action: { name: "analytics.get" } });
|
|
|
|
// Derived from canonical enum so a new measurement type is auto-summarised
|
|
// by /api/analytics (V3 audit: enum drift cousins).
|
|
const types = [...measurementTypeEnum.options] as MeasurementType[];
|
|
|
|
const measurementsByType = await Promise.all(
|
|
types.map((type) =>
|
|
prisma.measurement
|
|
.findMany({
|
|
where: { userId: user.id, type },
|
|
orderBy: { measuredAt: "asc" },
|
|
select: { value: true, measuredAt: true },
|
|
})
|
|
.then((measurements) => ({
|
|
type,
|
|
summary: summarize(
|
|
measurements.map(
|
|
(m): DataPoint => ({
|
|
date: m.measuredAt,
|
|
value: m.value,
|
|
}),
|
|
),
|
|
),
|
|
})),
|
|
),
|
|
);
|
|
|
|
const results: Record<string, ReturnType<typeof summarize>> = {};
|
|
for (const { type, summary } of measurementsByType) {
|
|
results[type] = summary;
|
|
}
|
|
|
|
// BMI calculation
|
|
let bmi: number | null = null;
|
|
if (user.heightCm && results.WEIGHT?.latest) {
|
|
const heightM = user.heightCm / 100;
|
|
bmi = Math.round((results.WEIGHT.latest / (heightM * heightM)) * 10) / 10;
|
|
}
|
|
|
|
// BP in-target percentage (auto-calculated from date of birth)
|
|
let bpInTargetPct: number | null = null;
|
|
let bpInTargetPct7d: number | null = null;
|
|
let bpInTargetPct30d: number | null = null;
|
|
const bpTargets = getBpTargets(user.dateOfBirth);
|
|
if (bpTargets) {
|
|
const now = new Date();
|
|
// v1.4.19 A1 — fetch ALL paired BP rows, not just the trailing 30
|
|
// days. Up to v1.4.18 we filtered to the last 30 days at the DB
|
|
// level and the headline (`bpInTargetPct`) was routed through
|
|
// `windows.last30Days?.pct` — making the headline a literal copy
|
|
// of the `30T` sub-value. For Marc's data (572 paired readings,
|
|
// recent 30d = 50 %, all-time ≈ 11 %) the tile pinned 50/50/50
|
|
// and looked algorithmically broken. The windowed helper now also
|
|
// returns an independent `allTime` aggregate, which we surface as
|
|
// the headline so the three numbers can diverge naturally.
|
|
const [sysData, diaData] = await Promise.all([
|
|
prisma.measurement.findMany({
|
|
where: { userId: user.id, type: "BLOOD_PRESSURE_SYS" },
|
|
select: { measuredAt: true, value: true },
|
|
}),
|
|
prisma.measurement.findMany({
|
|
where: { userId: user.id, type: "BLOOD_PRESSURE_DIA" },
|
|
select: { measuredAt: true, value: true },
|
|
}),
|
|
]);
|
|
|
|
const windows = computeBpInTargetWindows(sysData, diaData, bpTargets, now);
|
|
bpInTargetPct = windows.allTime?.pct ?? null;
|
|
bpInTargetPct7d = windows.last7Days?.pct ?? null;
|
|
bpInTargetPct30d = windows.last30Days?.pct ?? null;
|
|
}
|
|
|
|
// Per-context glucose summaries (canonical mg/dL).
|
|
const glucoseRows = await prisma.measurement.findMany({
|
|
where: { userId: user.id, type: "BLOOD_GLUCOSE" },
|
|
orderBy: { measuredAt: "asc" },
|
|
select: { value: true, measuredAt: true, glucoseContext: true },
|
|
});
|
|
const glucoseByContext: Record<string, ReturnType<typeof summarize>> = {};
|
|
if (glucoseRows.length > 0) {
|
|
const contexts = ["FASTING", "POSTPRANDIAL", "RANDOM", "BEDTIME"] as const;
|
|
for (const ctx of contexts) {
|
|
const ctxRows = glucoseRows.filter((r) => r.glucoseContext === ctx);
|
|
if (ctxRows.length === 0) continue;
|
|
glucoseByContext[ctx] = summarize(
|
|
ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })),
|
|
);
|
|
}
|
|
}
|
|
|
|
return apiSuccess({
|
|
summaries: results,
|
|
bmi,
|
|
bpInTargetPct,
|
|
bpInTargetPct7d,
|
|
bpInTargetPct30d,
|
|
glucoseByContext,
|
|
});
|
|
});
|