From 8f730484e0e5a4e1be15ab3b0c2f1579d9c863f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 4 Oct 2026 11:37:57 +0200 Subject: [PATCH] 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. --- .../coach-availability-window-honesty.test.ts | 31 +++++++ src/lib/ai/coach/tools/availability.ts | 55 ++++++++++- .../comparison-snapshot-window.test.ts | 34 ++++++- src/lib/insights/comprehensive-generate.ts | 58 +++++++++++- .../pulse-comparison-availability.test.ts | 91 +++++++++++++++++++ 5 files changed, 258 insertions(+), 11 deletions(-) create mode 100644 tests/integration/pulse-comparison-availability.test.ts diff --git a/src/lib/ai/coach/__tests__/coach-availability-window-honesty.test.ts b/src/lib/ai/coach/__tests__/coach-availability-window-honesty.test.ts index 06634c8c7..e34055329 100644 --- a/src/lib/ai/coach/__tests__/coach-availability-window-honesty.test.ts +++ b/src/lib/ai/coach/__tests__/coach-availability-window-honesty.test.ts @@ -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( diff --git a/src/lib/ai/coach/tools/availability.ts b/src/lib/ai/coach/tools/availability.ts index 623e5bb30..c7c35110c 100644 --- a/src/lib/ai/coach/tools/availability.ts +++ b/src/lib/ai/coach/tools/availability.ts @@ -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(); 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> { + const hourly = types.filter((t) => usesHourlyMeanDay(t)); + const out = new Map(); + 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, diff --git a/src/lib/insights/__tests__/comparison-snapshot-window.test.ts b/src/lib/insights/__tests__/comparison-snapshot-window.test.ts index 98adc7732..56d5b293f 100644 --- a/src/lib/insights/__tests__/comparison-snapshot-window.test.ts +++ b/src/lib/insights/__tests__/comparison-snapshot-window.test.ts @@ -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({ diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index dd98110c5..5469072e9 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -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); } +/** + * 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 { + 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>` + 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 => { - 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; }; diff --git a/tests/integration/pulse-comparison-availability.test.ts b/tests/integration/pulse-comparison-availability.test.ts new file mode 100644 index 000000000..06304bda5 --- /dev/null +++ b/tests/integration/pulse-comparison-availability.test.ts @@ -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, + }), + ]); + }); +});