Average pulse by day in the comparison snapshot and Coach inventory

The insights comparison snapshot took its current and baseline pulse figures
from a reading AVG, and the Coach's data inventory told the model a reading
mean of the whole pulse history. Both now read the mean of the user's local
days, each the mean of its hours' means, through the day-mean helper. Every
other type keeps its aggregate.
This commit is contained in:
Marc-André Bombeck committed 2026-10-04 11:37:57 +02:00
1 parent 11e35a6ba1
commit 8f730484e0
5 files changed
+258 -11

No files matched your search

@@ -48,6 +48,9 @@ interface GroupRow {
}
let measurementRows: GroupRow[] = [];
let measurementThrows = false;
/** What the day-weighted pulse query returns, per `type|unit`. */
let pulseDayMeans: Array<{ type: string; unit: string | null; mean: number }> =
[];
const workoutAggregate = vi.fn();
const moodAggregate = vi.fn();
const intakeAggregate = vi.fn();
@@ -66,6 +69,7 @@ vi.mock("@/lib/db", () => ({
);
},
},
$queryRaw: () => Promise.resolve(pulseDayMeans),
workout: { aggregate: (a: unknown) => workoutAggregate(a) },
moodEntry: { aggregate: (a: unknown) => moodAggregate(a) },
medicationIntakeEvent: { aggregate: (a: unknown) => intakeAggregate(a) },
@@ -386,6 +390,33 @@ describe("classifyAvailability", () => {
});
describe("probeCoachAvailability", () => {
// A pulse history's mean is the mean of its days, each the mean of its
// hours' means; the reading AVG a workout's dense samples tip is not what
// the Coach is told.
it("states the day-weighted pulse mean, not the reading mean", async () => {
measurementRows = [
{
type: "PULSE",
unit: "bpm",
_count: { _all: 930 },
_min: { measuredAt: new Date("2026-07-20T00:05:00Z"), value: 60 },
_max: { measuredAt: new Date("2026-07-20T23:55:00Z"), value: 150 },
_avg: { value: 127.7 },
},
];
pulseDayMeans = [{ type: "PULSE", unit: "bpm", mean: 63.75 }];
const probed = await probeCoachAvailability(
"u1",
new Map([["pulse", { kind: "measurement", types: ["PULSE"] }]]),
{ now: NOW },
);
pulseDayMeans = [];
const series = [...probed.values()][0]?.series;
expect(series).toEqual([
expect.objectContaining({ series: "PULSE", mean: 63.8, count: 930 }),
]);
});
it("returns no entry for a domain the record is empty for", async () => {
measurementRows = [];
const probed = await probeCoachAvailability(
+53 -2
View File
@@ -44,7 +44,9 @@
* is enough for the Coach to answer the question instead of only naming it.
*/
import { prisma } from "@/lib/db";
import type { MeasurementType } from "@/generated/prisma/client";
import { Prisma, type MeasurementType } from "@/generated/prisma/client";
import { dayWeightedRowsSql, windowMeanSql } from "@/lib/measurements/day-mean";
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
import { userDayKey } from "@/lib/tz/format";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { annotate } from "@/lib/logging/context";
@@ -155,13 +157,18 @@ async function readMeasurementBounds(
_max: { measuredAt: true, value: true },
_avg: { value: true },
});
const dayMeans = await readHourlyMeanTypeMeans(userId, types);
const out = new Map<MeasurementType, RawBounds>();
for (const row of rows) {
const firstAt = row._min.measuredAt;
const lastAt = row._max.measuredAt;
const min = row._min.value;
const max = row._max.value;
const mean = row._avg.value;
// A pulse mean is the mean of its days, each the mean of its hours'
// means, so a dense workout hour does not stand in for the history.
const mean = usesHourlyMeanDay(row.type)
? (dayMeans.get(`${row.type}|${row.unit}`) ?? null)
: row._avg.value;
if (!firstAt || !lastAt) continue;
const existing = out.get(row.type);
const unitEntry =
@@ -197,6 +204,50 @@ async function readMeasurementBounds(
return out;
}
/**
* The all-time mean per type and unit for the hourly-mean types among
* `types` (pulse, see `day-mean.ts`): the mean of the user's local days, each
* the mean of its local hours' means. Empty when none of `types` is one.
*/
async function readHourlyMeanTypeMeans(
userId: string,
types: readonly MeasurementType[],
): Promise<Map<string, number>> {
const hourly = types.filter((t) => usesHourlyMeanDay(t));
const out = new Map<string, number>();
if (hourly.length === 0) return out;
const tz = await resolveUserTimezone(userId);
const rows = await prisma.$queryRaw<
Array<{ type: string; unit: string | null; mean: number | null }>
>`
WITH src AS (
SELECT *
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
AND m."type" IN (${Prisma.join(
hourly.map((t) => Prisma.sql`${t}::measurement_type`),
)})
)
SELECT
m."type"::text AS type,
m."unit" AS unit,
${Prisma.raw(
windowMeanSql({
typeColumn: 'm."type"',
value: 'm."value"',
weight: "m.day_weight",
}),
)}::double precision AS mean
FROM ${dayWeightedRowsSql("src", tz)} m
GROUP BY m."type", m."unit"
`;
for (const r of rows) {
if (r.mean !== null) out.set(`${r.type}|${r.unit}`, Number(r.mean));
}
return out;
}
/** One `aggregate()` per non-measurement subject kind. */
async function readTableBounds(
userId: string,
@@ -23,6 +23,7 @@ import { summarize, type DataPoint } from "@/lib/analytics/trends";
const findUnique = vi.fn();
const measurementFindMany = vi.fn();
const measurementAggregate = vi.fn();
const queryRaw = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
@@ -33,6 +34,7 @@ vi.mock("@/lib/db", () => ({
findMany: (...a: unknown[]) => measurementFindMany(...a),
aggregate: (...a: unknown[]) => measurementAggregate(...a),
},
$queryRaw: (...a: unknown[]) => queryRaw(...a),
},
}));
// SLEEP_DURATION branch helpers — most tests never exercise the sleep
@@ -84,6 +86,8 @@ beforeEach(() => {
measurementFindMany.mockResolvedValue([]);
// No measurements by default; individual tests override per call.
measurementAggregate.mockResolvedValue({ _avg: { value: null } });
// Pulse reads its day-weighted window mean through one raw query per window.
queryRaw.mockResolvedValue([]);
});
describe("buildComparisonSnapshotForUser — aggregate windows", () => {
@@ -131,9 +135,10 @@ describe("buildComparisonSnapshotForUser — aggregate windows", () => {
expect(lteMs).toBeGreaterThanOrEqual(before - 30 * DAY);
expect(lteMs).toBeLessThanOrEqual(after - 30 * DAY);
}
// Six non-sleep types × 2 windows = 12 aggregate reads; no raw non-sleep read.
expect(currentCalls.length).toBe(6);
expect(baselineCalls.length).toBe(6);
// Five non-sleep, non-pulse types × 2 windows = 10 aggregate reads; no raw
// non-sleep read. Pulse reads its day-weighted mean instead (below).
expect(currentCalls.length).toBe(5);
expect(baselineCalls.length).toBe(5);
});
it("reads SLEEP_DURATION through the raw ~400-day findMany, never aggregate", async () => {
@@ -163,7 +168,7 @@ describe("buildComparisonSnapshotForUser — aggregate windows", () => {
.measuredAt,
)
.filter((m): m is { gt: Date; lte: Date } => m.lte != null);
expect(baselineCalls.length).toBe(6);
expect(baselineCalls.length).toBe(5);
for (const w of baselineCalls) {
const gtMs = w.gt.getTime();
const lteMs = w.lte.getTime();
@@ -175,6 +180,27 @@ describe("buildComparisonSnapshotForUser — aggregate windows", () => {
});
});
describe("buildComparisonSnapshotForUser — pulse", () => {
// A pulse window average is the mean of its days, each the mean of its
// hours' means, so a workout's dense readings do not decide the delta. A
// bare AVG cannot express it, so pulse never goes through the aggregate.
it("reads pulse through the day-weighted query, never the reading AVG", async () => {
queryRaw
.mockResolvedValueOnce([{ mean: 64 }])
.mockResolvedValueOnce([{ mean: 60 }]);
const snapshot = await buildComparisonSnapshotForUser("u1");
const pulseAggregates = measurementAggregate.mock.calls.filter(
(c) => (c[0] as { where: { type: string } }).where.type === "PULSE",
);
expect(pulseAggregates).toHaveLength(0);
expect(queryRaw).toHaveBeenCalledTimes(2);
expect(JSON.stringify(queryRaw.mock.calls[0])).toContain("day_weight");
const pulse = snapshot!.metrics.find((m) => m.type === "pulse");
expect(pulse).toMatchObject({ unit: "bpm" });
expect([pulse!.currentAvg, pulse!.baselineAvg].sort()).toEqual([60, 64]);
});
});
describe("buildComparisonSnapshotForUser — output-equivalence to the raw-average path", () => {
it("emits identical metrics to findMany-then-summarize() for a fixture (lastMonth)", async () => {
findUnique.mockResolvedValue({
+53 -5
View File
@@ -78,6 +78,9 @@ import {
type ComparisonBaseline,
} from "@/lib/dashboard-layout";
import type { MeasurementType, Prisma } from "@/generated/prisma/client";
import { Prisma as PrismaSql } from "@/generated/prisma/client";
import { dayWeightedRowsSql, windowMeanSql } from "@/lib/measurements/day-mean";
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
import { insightResultSchema, type InsightResult } from "@/lib/ai/types";
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import { AllProvidersFailedError } from "@/lib/ai/provider-runner";
@@ -321,6 +324,48 @@ function parseComprehensiveResult(
: (parsed as Record<string, unknown>);
}
/**
* The window average of an hourly-mean type (pulse, see `day-mean.ts`) for
* the comparison snapshot: the mean of the user's local days in the window,
* each the mean of its local hours' means, so the current-vs-baseline delta
* compares the statistic the dashboard tile shows rather than a reading mean
* a workout tips.
*/
export async function hourlyMeanWindowAverage(
userId: string,
type: MeasurementType,
measuredAt: { gt: Date; lte?: Date },
): Promise<number | null> {
const tz = await resolveUserTimezone(userId);
const upper =
measuredAt.lte === undefined
? PrismaSql.empty
: PrismaSql.sql`AND m."measured_at" <= ${measuredAt.lte}`;
const [row] = await prisma.$queryRaw<Array<{ mean: number | null }>>`
WITH src AS (
SELECT *
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."type" = ${type}::measurement_type
AND m."deleted_at" IS NULL
AND m."measured_at" > ${measuredAt.gt}
${upper}
)
SELECT ${PrismaSql.raw(
windowMeanSql({
typeColumn: 'm."type"',
value: 'm."value"',
weight: "m.day_weight",
}),
)}::double precision AS mean
FROM ${dayWeightedRowsSql("src", tz)} m
GROUP BY m."type"
`;
return row?.mean === null || row?.mean === undefined
? null
: Number(row.mean);
}
/**
* Comparison-snapshot builder — shared with the on-demand route. Returns
* null when the user's comparison toggle is off (most users), so the
@@ -406,11 +451,14 @@ export async function buildComparisonSnapshotForUser(
type: MeasurementType,
measuredAt: { gt: Date; lte?: Date },
): Promise<number | null> => {
const agg = await prisma.measurement.aggregate({
where: { userId, type, deletedAt: null, measuredAt },
_avg: { value: true },
});
const avg = agg._avg.value;
const avg = usesHourlyMeanDay(type)
? await hourlyMeanWindowAverage(userId, type, measuredAt)
: (
await prisma.measurement.aggregate({
where: { userId, type, deletedAt: null, measuredAt },
_avg: { value: true },
})
)._avg.value;
return avg === null ? null : Math.round(avg * 100) / 100;
};
@@ -0,0 +1,91 @@
/**
* The comparison snapshot's window average and the Coach's availability mean
* read pulse as the mean of its days, each the mean of its hours' means, on a
* real Postgres.
*
* Two days, three days back and two days back (inside the 30-day window): a
* workout day with one hour of 12 readings at 150 and three resting hours of
* one reading at 60 (day value 82.5), and a resting day of two hours at 70.
* Mean of the days: 76.25. Mean of the readings: (12 x 150 + 3 x 60 + 2 x 70)
* / 17 = 124.7.
*/
import { beforeAll, describe, expect, it } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import type { Prisma } from "@/generated/prisma/client";
import { probeCoachAvailability } from "@/lib/ai/coach/tools/availability";
import { hourlyMeanWindowAverage } from "@/lib/insights/comprehensive-generate";
import { invalidateUserTimezone } from "@/lib/tz/resolver";
const prisma = getPrismaClient();
const USER = "pulse-comparison-availability";
const DAY = 86_400_000;
const now = new Date();
const D1 = Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate() - 3,
);
const D2 = D1 + DAY;
const DAY_MEAN = (82.5 + 70) / 2;
const READING_MEAN = (12 * 150 + 3 * 60 + 2 * 70) / 17;
beforeAll(async () => {
await truncateAllTables(prisma);
await prisma.user.create({
data: { id: USER, username: USER, timezone: "UTC" },
});
invalidateUserTimezone(USER);
const rows: Prisma.MeasurementCreateManyInput[] = [];
const add = (at: number, value: number) =>
rows.push({
id: `${USER}-${rows.length}`,
userId: USER,
type: "PULSE",
unit: "bpm",
source: "APPLE_HEALTH",
value,
measuredAt: new Date(at),
});
for (let i = 0; i < 12; i += 1) add(D1 + 10 * 3_600_000 + i * 300_000, 150);
for (const h of [12, 14, 16]) add(D1 + h * 3_600_000, 60);
for (const h of [8, 9]) add(D2 + h * 3_600_000, 70);
await prisma.measurement.createMany({ data: rows });
}, 120_000);
describe("pulse window means on a real database", () => {
it("the fixture separates the two statistics", () => {
expect(READING_MEAN).toBeCloseTo(124.71, 2);
expect(DAY_MEAN).toBe(76.25);
});
it("the comparison snapshot's window average is the mean of the days", async () => {
const avg = await hourlyMeanWindowAverage(USER, "PULSE", {
gt: new Date(Date.now() - 30 * DAY),
});
expect(avg).toBeCloseTo(DAY_MEAN, 9);
const empty = await hourlyMeanWindowAverage(USER, "PULSE", {
gt: new Date(Date.now() - 60 * DAY),
lte: new Date(Date.now() - 30 * DAY),
});
expect(empty).toBeNull();
});
it("the Coach availability mean is the mean of the days", async () => {
const probed = await probeCoachAvailability(
USER,
new Map([["pulse", { kind: "measurement", types: ["PULSE"] }]]),
{ now },
);
const series = probed.get("pulse")?.series;
expect(series).toEqual([
expect.objectContaining({
series: "PULSE",
count: 17,
mean: Math.round(DAY_MEAN * 10) / 10,
min: 60,
max: 150,
}),
]);
});
});