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

@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
readRollupBuckets: vi.fn(),
findMany: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: { measurement: { findMany: mocks.findMany } },
}));
vi.mock("@/lib/rollups/measurement-rollups", () => ({
readRollupBuckets: mocks.readRollupBuckets,
}));
vi.mock("@/lib/rollups/measurement-read", () => ({
loadUserSourcePriority: vi.fn(async () => null),
}));
vi.mock("@/lib/rollups/measurement-read-wmy", () => ({
aggregateWmyBuckets: vi.fn(),
readBestGranularityRollups: vi.fn(async () => null),
}));
vi.mock("@/lib/tz/resolver", () => ({
resolveUserTimezone: vi.fn(async () => "UTC"),
}));
import { computeRangeDelta } from "@/lib/analytics/range-delta";
const DAY_MS = 86_400_000;
const NOW = Date.UTC(2026, 5, 30, 12);
function dayRow(daysAgo: number, total: number, samples: number) {
const bucketStart = new Date(Date.UTC(2026, 5, 30) - daysAgo * DAY_MS);
return {
bucketStart,
count: samples,
mean: total / samples,
minValue: 0,
maxValue: total,
sd: null,
slope: null,
r2: null,
computedAt: bucketStart,
};
}
beforeEach(() => {
mocks.readRollupBuckets.mockReset();
mocks.findMany.mockReset();
});
describe("computeRangeDelta — per-day and per-night metrics", () => {
it("compares average daily step totals, not the mean of the readings", async () => {
// Six complete days of 8,000 in the previous week, and in the current
// week five drained days plus today still arriving as 200 samples.
mocks.readRollupBuckets.mockResolvedValue([
...[8, 9, 10, 11, 12, 13].map((d) => dayRow(d, 8000, 1)),
...[1, 2, 3, 4, 5].map((d) => dayRow(d, 8000, 1)),
dayRow(0, 4000, 200),
]);
const result = await computeRangeDelta("u", "ACTIVITY_STEPS", "7d", NOW);
expect(result.granularity).toBe("DAY");
expect(result.previous).toMatchObject({ count: 6, mean: 8000 });
expect(result.current.count).toBe(6);
expect(result.current.mean).toBeCloseTo((5 * 8000 + 4000) / 6, 6);
expect(result.current.sum).toBeCloseTo(44_000, 6);
});
it("compares average nights of sleep, not the mean of stage rows", async () => {
const row = (iso: string, minutes: number, sleepStage: string) => ({
value: minutes,
measuredAt: new Date(iso),
sleepStage,
source: "APPLE_HEALTH",
deviceType: null,
});
// Two nights: 7 h asleep (split into three stage rows plus an in-bed
// envelope) and 6 h asleep.
mocks.findMany.mockResolvedValue([
row("2026-06-28T01:00:00.000Z", 480, "IN_BED"),
row("2026-06-28T02:00:00.000Z", 180, "CORE"),
row("2026-06-28T04:00:00.000Z", 120, "DEEP"),
row("2026-06-28T06:00:00.000Z", 120, "REM"),
row("2026-06-29T06:00:00.000Z", 360, "ASLEEP"),
]);
const result = await computeRangeDelta("u", "SLEEP_DURATION", "7d", NOW);
expect(result.granularity).toBe("live");
expect(result.current.count).toBe(2);
expect(result.current.mean).toBeCloseTo(390, 6);
});
});
+137
View File
@@ -25,14 +25,34 @@
* a window aggregate built from WEEK / MONTH buckets equals the per-row
* aggregate over the same span. SD / slope / r² are intentionally NOT part of
* the delta — they do not compose, matching the rest of the WMY reader tier.
*
* Two kinds of metric are not averaged per reading:
* - step-like totals (steps, energy, distance, flights, …) compare the
* average DAILY total: a day's readings are pieces of one total, and the
* last day and a half still arrives as many small samples before the
* drain folds them, so a per-reading mean collapsed the current window;
* - sleep compares the average NIGHT (time asleep, reconstructed from the
* stage rows): a per-row mean averaged stage segments and in-bed
* envelopes, which is no amount of sleep at all.
* For both, `count` is the number of days or nights.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
import {
reconstructSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import {
aggregateWmyBuckets,
readBestGranularityRollups,
type RollupBucketRow,
} from "@/lib/rollups/measurement-read-wmy";
import { startOfUtcDay } from "@/lib/tz/start-of-utc-day";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import {
ANALYTICS_RANGES,
rangeWindowDays,
@@ -86,6 +106,99 @@ export function sliceWindowDelta(
};
}
/**
* Aggregate one value per day or night (a daily total, a night's time
* asleep): `count` is the number of days or nights, `mean` their average.
*/
export function aggregatePerPeriod(values: readonly number[]): WindowAggregate {
if (values.length === 0) {
return { count: 0, min: null, max: null, mean: null, sum: null };
}
let sum = 0;
let min = Infinity;
let max = -Infinity;
for (const v of values) {
sum += v;
if (v < min) min = v;
if (v > max) max = v;
}
return { count: values.length, min, max, mean: sum / values.length, sum };
}
/** Split `(at, value)` points into the current and previous halves. */
function splitPerPeriod(
points: ReadonlyArray<{ at: Date; value: number }>,
windowDays: number,
now: number,
): { current: WindowAggregate; previous: WindowAggregate } {
const currentStart = now - windowDays * DAY_MS;
const previousStart = now - 2 * windowDays * DAY_MS;
const current: number[] = [];
const previous: number[] = [];
for (const p of points) {
const t = p.at.getTime();
if (t >= currentStart && t < now) current.push(p.value);
else if (t >= previousStart && t < currentStart) previous.push(p.value);
}
return {
current: aggregatePerPeriod(current),
previous: aggregatePerPeriod(previous),
};
}
/** The canonical daily totals of a step-like metric, one point per day. */
async function readDailyTotals(
userId: string,
type: MeasurementType,
windowDays: number,
now: number,
): Promise<Array<{ at: Date; value: number }>> {
const rows = await readRollupBuckets(
userId,
type,
"DAY",
startOfUtcDay(new Date(now - 2 * windowDays * DAY_MS)),
new Date(now),
);
return rows.map((r) => ({ at: r.bucketStart, value: r.mean * r.count }));
}
/** Time asleep per reconstructed night, one point per night. */
async function readNightlySleep(
userId: string,
windowDays: number,
now: number,
): Promise<Array<{ at: Date; value: number }>> {
// A night's rows start the evening before its wake day: read one day more.
const rows = (await prisma.measurement.findMany({
where: {
userId,
type: "SLEEP_DURATION",
deletedAt: null,
measuredAt: {
gte: new Date(now - (2 * windowDays + 1) * DAY_MS),
lt: new Date(now),
},
},
orderBy: { measuredAt: "asc" },
select: {
value: true,
measuredAt: true,
sleepStage: true,
source: true,
deviceType: true,
},
})) as SleepStageRow[];
if (rows.length === 0) return [];
const [tz, priorityJson] = await Promise.all([
resolveUserTimezone(userId),
loadUserSourcePriority(userId),
]);
return reconstructSleepNights(rows, tz, priorityJson)
.filter((n) => n.asleepMinutes > 0)
.map((n) => ({ at: n.measuredAt, value: n.asleepMinutes }));
}
/**
* Compose the period-over-period delta from the two window aggregates.
* Pure — pinned by unit test. Guards both the missing-data case (either
@@ -119,6 +232,30 @@ export async function computeRangeDelta(
now: number = Date.now(),
): Promise<RangeDeltaResult> {
const windowDays = rangeWindowDays(range);
if (type === "SLEEP_DURATION" || CUMULATIVE_HK_TYPES.has(type)) {
const points =
type === "SLEEP_DURATION"
? await readNightlySleep(userId, windowDays, now)
: await readDailyTotals(userId, type, windowDays, now);
const { current, previous } = splitPerPeriod(points, windowDays, now);
const { delta, deltaPct } = composeDelta(current, previous);
return {
range,
windowDays,
granularity:
points.length === 0
? "none"
: type === "SLEEP_DURATION"
? "live"
: "DAY",
current,
previous,
delta,
deltaPct,
};
}
// Read the full 2N span in one go so the current and previous halves share
// a granularity; the router picks the coarsest tier that resolves 2N.
const resolved = await readBestGranularityRollups(