mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user