mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
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:
1 parent
b2bb81cac3
commit
4e7beb797c
7 files changed
+396
-29
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user