fix(analytics): all-time extremes and range deltas count one source per day

The all-time weight, blood-pressure and pulse figures the insight features
carry averaged every raw row, so a device that samples densely outweighed the
readings the source ladder prefers on the same days. They now keep one source
per day, like the dashboard's all-time figures.

The period-over-period range delta averaged readings. For step-like totals
the current window still holds the last day and a half as many small
samples, so the average collapsed; it now compares average daily totals. For
sleep it averaged stage segments and in-bed envelopes; it now compares
average nights of time asleep. The API descriptions say which.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 16:07:45 +02:00
1 parent b2bb81cac3
commit 4e7beb797c
7 files changed
+396 -29

No files matched your search

+5 -3
View File
@@ -3,7 +3,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
measurement: { findMany: vi.fn(), groupBy: vi.fn() },
measurement: { findMany: vi.fn() },
measurementRollup: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn(), findFirst: vi.fn() },
moodEntryRollup: { findMany: vi.fn(), findFirst: vi.fn() },
@@ -15,6 +15,8 @@ vi.mock("@/lib/db", () => ({
workout: { findMany: vi.fn() },
ecgRecording: { findMany: vi.fn() },
$queryRawUnsafe: vi.fn(),
// The all-time extremes read (one grouped aggregate).
$queryRaw: vi.fn(),
},
}));
@@ -49,7 +51,6 @@ const prismaMock = prisma as unknown as {
user: { findUnique: ReturnType<typeof vi.fn> };
measurement: {
findMany: ReturnType<typeof vi.fn>;
groupBy: ReturnType<typeof vi.fn>;
};
measurementRollup: { findMany: ReturnType<typeof vi.fn> };
moodEntry: {
@@ -66,6 +67,7 @@ const prismaMock = prisma as unknown as {
measurementReminder: { findMany: ReturnType<typeof vi.fn> };
workout: { findMany: ReturnType<typeof vi.fn> };
ecgRecording: { findMany: ReturnType<typeof vi.fn> };
$queryRaw: ReturnType<typeof vi.fn>;
};
const dayMs = 24 * 60 * 60 * 1000;
@@ -123,7 +125,7 @@ beforeEach(() => {
gender: "MALE",
});
prismaMock.measurement.findMany.mockResolvedValue([]);
prismaMock.measurement.groupBy.mockResolvedValue([]);
prismaMock.$queryRaw.mockResolvedValue([]);
prismaMock.measurementRollup.findMany.mockResolvedValue([]);
prismaMock.moodEntry.findMany.mockResolvedValue([]);
prismaMock.moodEntry.findFirst.mockResolvedValue(null);
+57 -15
View File
@@ -10,6 +10,9 @@
* point (`extractFeatures`) stays in the hub.
*/
import { prisma } from "@/lib/db";
import { Prisma } from "@/generated/prisma/client";
import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { classifyAgainstEffectiveRange } from "@/lib/labs/reference-range";
import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day";
import { resolveLabFields } from "@/lib/labs/serialise";
@@ -33,10 +36,16 @@ interface AllTimeExtremes {
/**
* v1.18.11 P1 — full-history min / max / mean per measurement type via ONE
* grouped SQL aggregation, with NO row materialisation in JS. Used to fill the
* `allTime*` feature fields honestly when the bulk feature read is bounded to a
* recent window: the windowed `summarize()` covers trends + recent windows, and
* this covers the long-horizon extremes the prompt labels "allTime".
* grouped SQL aggregation, with NO row materialisation in JS. Fills the
* `allTime*` feature fields; the windowed `summarize()` covers trends + recent
* windows, and this covers the long-horizon figures the prompt labels
* "allTime".
*
* One source per (type, day): each source is folded per day first, each day
* keeps its ladder-canonical source, then the days fold into the figures —
* the same pick the dashboard's all-time figures make. Averaging every raw
* row let a device that samples densely (a watch's pulse) outweigh the
* readings the source ladder prefers on the same days.
*
* Only the four types that expose `allTime*` fields are aggregated (weight,
* systolic, diastolic, pulse). Returns a map keyed by `MeasurementType`; a type
@@ -46,19 +55,52 @@ export async function readAllTimeExtremes(
userId: string,
types: readonly MeasurementType[],
): Promise<Map<MeasurementType, AllTimeExtremes>> {
const rows = await prisma.measurement.groupBy({
by: ["type"],
where: { userId, deletedAt: null, type: { in: [...types] } },
_avg: { value: true },
_min: { value: true },
_max: { value: true },
});
const out = new Map<MeasurementType, AllTimeExtremes>();
if (types.length === 0) return out;
const priorityJson = await loadUserSourcePriority(userId);
const rank = Prisma.raw(
buildSourceRankCase(priorityJson, 'p."type"', 'p."source"'),
);
const typeList = Prisma.join(
types.map((t) => Prisma.sql`${t}::measurement_type`),
);
const rows = await prisma.$queryRaw<
Array<{ type: string; mean: number; min: number; max: number }>
>`
WITH per_source AS (
SELECT
m."type",
m."source",
date_trunc('day', m."measured_at") AS day,
COUNT(*)::int AS cnt,
SUM(m."value")::double precision AS total,
MIN(m."value")::double precision AS min_value,
MAX(m."value")::double precision AS max_value
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
AND m."type" IN (${typeList})
GROUP BY m."type", m."source", 3
),
canon AS (
SELECT DISTINCT ON (p."type", p.day)
p."type", p.cnt, p.total, p.min_value, p.max_value
FROM per_source p
ORDER BY p."type", p.day, (${rank}), p."source"::text
)
SELECT
c."type"::text AS type,
(SUM(c.total) / SUM(c.cnt))::double precision AS mean,
MIN(c.min_value)::double precision AS min,
MAX(c.max_value)::double precision AS max
FROM canon c
GROUP BY c."type"
`;
for (const r of rows) {
out.set(r.type, {
mean: r._avg.value ?? null,
min: r._min.value ?? null,
max: r._max.value ?? null,
out.set(r.type as MeasurementType, {
mean: r.mean === null ? null : Number(r.mean),
min: r.min === null ? null : Number(r.min),
max: r.max === null ? null : Number(r.max),
});
}
return out;