mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Average pulse by day in the insights, Coach and report trend
The feature extraction, the signals of the day, the status cards, the graded series, the Coach snapshot, the mood correlations and the report's trend arrow each still averaged pulse over its readings, so a dense workout hour decided the day and a workout day the window. They now read a pulse day as the mean of its hours' means and a window as the mean of its days, through the day-mean helper. Counts, extremes and spreads stay over every reading; every other type is unchanged.
This commit is contained in:
1 parent
db524ded98
commit
11e35a6ba1
21 files changed
+1060
-61
No files matched your search
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildCoreMetricsBlocks } from "../core-metrics-block";
|
||||
|
||||
const HOUR = 3_600_000;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
/**
|
||||
* The Coach's pulse timeline: a day is the mean of its hours' means and a week
|
||||
* the mean of its days (`day-mean.ts`). A workout day of twelve readings at
|
||||
* 150 in one hour and three resting hours at 60 reads 82.5, not 132.
|
||||
*/
|
||||
describe("Coach pulse block", () => {
|
||||
it("states a pulse day as the mean of its hours, a week as its days", () => {
|
||||
const first = Date.UTC(2026, 2, 4);
|
||||
const rows: Array<{ type: string; value: number; measuredAt: Date }> = [];
|
||||
const day = (start: number) => {
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
rows.push({
|
||||
type: "PULSE",
|
||||
value: 150,
|
||||
measuredAt: new Date(start + 10 * HOUR + i * 300_000),
|
||||
});
|
||||
}
|
||||
for (const h of [12, 14, 16]) {
|
||||
rows.push({
|
||||
type: "PULSE",
|
||||
value: 60,
|
||||
measuredAt: new Date(start + h * HOUR),
|
||||
});
|
||||
}
|
||||
rows.push({
|
||||
type: "PULSE",
|
||||
value: 60,
|
||||
measuredAt: new Date(start + DAY + 8 * HOUR),
|
||||
});
|
||||
};
|
||||
day(first - 14 * DAY); // older: lands in the weekly fold
|
||||
day(first); // recent
|
||||
const snapshot: Record<string, unknown> = {};
|
||||
buildCoreMetricsBlocks({
|
||||
sources: new Set(["pulse"]),
|
||||
features: { pulse: { coverage: { count: rows.length } } } as never,
|
||||
measurementRows: rows,
|
||||
moodRows: null,
|
||||
recentCutoff: new Date(first),
|
||||
userTz: "UTC",
|
||||
coarseTails: {},
|
||||
snapshot,
|
||||
windows: new Set(),
|
||||
metrics: new Set(),
|
||||
counts: {},
|
||||
registerBlock: () => {},
|
||||
groundingValues: new Map(),
|
||||
units: {} as never,
|
||||
});
|
||||
const pulse = snapshot.pulse as {
|
||||
timeline: {
|
||||
recent: Array<{ value: number }>;
|
||||
weekly: Array<{ mean: number; count: number }>;
|
||||
};
|
||||
};
|
||||
expect(pulse.timeline.recent.map((d) => d.value)).toEqual([82.5, 60]);
|
||||
expect(pulse.timeline.weekly).toHaveLength(1);
|
||||
expect(pulse.timeline.weekly[0].mean).toBe(71.3);
|
||||
expect(pulse.timeline.weekly[0].count).toBe(16);
|
||||
});
|
||||
});
|
||||
@@ -133,13 +133,21 @@ export function buildCoreMetricsBlocks(
|
||||
|
||||
if (ctx.sources.has("pulse") && ctx.features.pulse) {
|
||||
const rows = byType("PULSE");
|
||||
// A pulse day is the mean of its hours' means, a week the mean of its
|
||||
// days (`day-mean.ts`).
|
||||
ctx.snapshot.pulse = {
|
||||
aggregate: ctx.features.pulse,
|
||||
timeline: {
|
||||
recent: buildDailyValueRows(rows, ctx.recentCutoff, ctx.userTz),
|
||||
recent: buildDailyValueRows(
|
||||
rows,
|
||||
ctx.recentCutoff,
|
||||
ctx.userTz,
|
||||
"PULSE",
|
||||
),
|
||||
weekly: bucketWeekly(
|
||||
rows.filter((row) => row.measuredAt < ctx.recentCutoff),
|
||||
ctx.userTz,
|
||||
"PULSE",
|
||||
),
|
||||
...(ctx.coarseTails.pulse ? { coarse: ctx.coarseTails.pulse } : {}),
|
||||
},
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
* is one-way (snapshot → series), never back.
|
||||
*/
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { dayValue, readingsMean } from "@/lib/measurements/day-mean";
|
||||
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
|
||||
import {
|
||||
applyDisplayTransform,
|
||||
type DisplayTransform,
|
||||
@@ -135,11 +137,33 @@ function dailyMeans<T extends { measuredAt: Date; value: number }>(
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function groupRowsByDay(
|
||||
rows: Array<{ measuredAt: Date; value: number }>,
|
||||
tz: string,
|
||||
): Map<string, Array<{ measuredAt: Date; value: number }>> {
|
||||
const out = new Map<string, Array<{ measuredAt: Date; value: number }>>();
|
||||
for (const r of rows) {
|
||||
const key = tzDayKey(r.measuredAt, tz);
|
||||
const list = out.get(key);
|
||||
if (list) list.push(r);
|
||||
else out.set(key, [r]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* `type`, when given, names the measurement type: for a type whose day is the
|
||||
* mean of its hours' means (pulse, `day-mean.ts`) a week's mean is the mean of
|
||||
* its days and a day's value that day value. `count` stays the readings.
|
||||
*/
|
||||
export function bucketWeekly(
|
||||
rows: Array<{ measuredAt: Date; value: number }>,
|
||||
tz: string,
|
||||
type?: string,
|
||||
): WeeklyBucket[] {
|
||||
const hourly = type !== undefined && usesHourlyMeanDay(type);
|
||||
const grouped = new Map<string, number[]>();
|
||||
const members = new Map<string, Array<{ measuredAt: Date; value: number }>>();
|
||||
for (const r of rows) {
|
||||
const key = isoWeekKey(r.measuredAt, tz);
|
||||
const list = grouped.get(key);
|
||||
@@ -148,15 +172,23 @@ export function bucketWeekly(
|
||||
} else {
|
||||
grouped.set(key, [r.value]);
|
||||
}
|
||||
if (hourly) {
|
||||
const kept = members.get(key);
|
||||
if (kept) kept.push(r);
|
||||
else members.set(key, [r]);
|
||||
}
|
||||
}
|
||||
return Array.from(grouped.entries())
|
||||
.map(([weekISO, values]) => ({
|
||||
weekISO,
|
||||
mean:
|
||||
Math.round((values.reduce((s, v) => s + v, 0) / values.length) * 10) /
|
||||
10,
|
||||
count: values.length,
|
||||
}))
|
||||
.map(([weekISO, values]) => {
|
||||
const mean = hourly
|
||||
? (readingsMean(type!, members.get(weekISO) ?? [], tz) ?? 0)
|
||||
: values.reduce((s, v) => s + v, 0) / values.length;
|
||||
return {
|
||||
weekISO,
|
||||
mean: Math.round(mean * 10) / 10,
|
||||
count: values.length,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.weekISO.localeCompare(b.weekISO));
|
||||
}
|
||||
|
||||
@@ -164,12 +196,19 @@ export function buildDailyValueRows(
|
||||
rows: Array<{ measuredAt: Date; value: number }>,
|
||||
recentCutoff: Date,
|
||||
tz: string,
|
||||
type?: string,
|
||||
): DailyValueRow[] {
|
||||
const recent = rows.filter((r) => r.measuredAt >= recentCutoff);
|
||||
const grouped = dailyMeans(recent, tz);
|
||||
const hourly = type !== undefined && usesHourlyMeanDay(type);
|
||||
const members = hourly
|
||||
? groupRowsByDay(recent, tz)
|
||||
: new Map<string, Array<{ measuredAt: Date; value: number }>>();
|
||||
return Array.from(grouped.entries())
|
||||
.map(([date, info]) => {
|
||||
const mean = info.values.reduce((s, v) => s + v, 0) / info.values.length;
|
||||
const mean = hourly
|
||||
? (dayValue(type!, members.get(date) ?? [], tz) ?? 0)
|
||||
: info.values.reduce((s, v) => s + v, 0) / info.values.length;
|
||||
return {
|
||||
date,
|
||||
weekday: tzWeekday(info.date, tz),
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildClinicalSummaryLines } from "../clinical-summary";
|
||||
import type { DoctorReportData } from "../../doctor-report-data";
|
||||
|
||||
/**
|
||||
* The cover sheet's trend arrow compares the first half of a series with the
|
||||
* second. On the raw path a pulse series holds every reading, so its halves
|
||||
* were halves by reading count and a dense workout hour decided the arrow. It
|
||||
* now compares day values, each the mean of its hours' means.
|
||||
*
|
||||
* Day 1: three resting readings at 60. Day 2: a workout hour of twelve
|
||||
* readings at 150, then three resting hours at 60. By readings the halves are
|
||||
* equal (120 and 120, "→"); by day the pulse rose from 60 to 82.5 ("↑").
|
||||
*/
|
||||
function series(type: string) {
|
||||
const at = (d: number, h: number, m = 0) =>
|
||||
new Date(Date.UTC(2026, 2, d, h, m)).toISOString();
|
||||
return [
|
||||
...[8, 9, 10].map((h) => ({ value: 60, measuredAt: at(2, h) })),
|
||||
...Array.from({ length: 12 }, (_, i) => ({
|
||||
value: 150,
|
||||
measuredAt: at(3, 10, i * 5),
|
||||
})),
|
||||
...[12, 14, 16].map((h) => ({ value: 60, measuredAt: at(3, h) })),
|
||||
].map((p) => ({ ...p, type }));
|
||||
}
|
||||
|
||||
function arrowFor(type: string): string | undefined {
|
||||
const points = series(type).map(({ value, measuredAt }) => ({
|
||||
value,
|
||||
measuredAt,
|
||||
}));
|
||||
const data = {
|
||||
period: { days: 2, since: "", start: "", end: "" },
|
||||
measurements: { [type]: points },
|
||||
stats: {
|
||||
[type]: { avg: 0, min: 60, max: 150, count: points.length, latest: 60 },
|
||||
},
|
||||
compliance: {},
|
||||
} as unknown as DoctorReportData;
|
||||
const lines = buildClinicalSummaryLines(
|
||||
data,
|
||||
(key, vars) => `${key}|${String(vars?.arrow ?? "")}`,
|
||||
(n) => String(n),
|
||||
);
|
||||
return lines
|
||||
.find((l) => l.startsWith("doctorReport.summaryTrend"))
|
||||
?.split("|")[1];
|
||||
}
|
||||
|
||||
describe("clinical summary trend arrow", () => {
|
||||
it("reads pulse by day, not by reading count", () => {
|
||||
expect(arrowFor("PULSE")).toBe("↑");
|
||||
});
|
||||
|
||||
it("keeps the reading halves for every other type", () => {
|
||||
expect(arrowFor("WEIGHT")).toBe("→");
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
DoctorReportNumberFormatter,
|
||||
DoctorReportTranslator,
|
||||
} from "./render-context";
|
||||
import { dayValueRows } from "@/lib/measurements/day-mean";
|
||||
|
||||
export function getBmiClassificationKey(bmi: number): string {
|
||||
if (bmi < 18.5) return "doctorReport.bmiUnderweight";
|
||||
@@ -76,7 +77,22 @@ export function buildClinicalSummaryLines(
|
||||
const series = data.measurements[type];
|
||||
const stat = data.stats[type];
|
||||
if (!series || series.length === 0 || !stat) continue;
|
||||
const arrow = trendArrow(series.map((point) => point.value));
|
||||
// A pulse series on the raw path holds every reading, so halves of it
|
||||
// are halves by reading count and a dense workout hour tips the arrow.
|
||||
// Compare day values instead, each the mean of its hours' means (see
|
||||
// `day-mean.ts`); the report's day points already are one per day. Days
|
||||
// are UTC days: the arrow needs only their order. Every other type keeps
|
||||
// its points as they are.
|
||||
const arrow = trendArrow(
|
||||
dayValueRows(
|
||||
type,
|
||||
series.map((point) => ({
|
||||
value: point.value,
|
||||
measuredAt: new Date(point.measuredAt),
|
||||
})),
|
||||
"UTC",
|
||||
).map((point) => point.value),
|
||||
);
|
||||
lines.push(
|
||||
t("doctorReport.summaryTrend", {
|
||||
label: t(MEASUREMENT_TYPE_LABEL_KEYS[type as MeasurementType] ?? ""),
|
||||
|
||||
@@ -624,3 +624,61 @@ describe("extractFeatures — S10 ECG device-verdict descriptor", () => {
|
||||
expect(call?.select?.rhythmClassification).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractFeatures — a day of pulse is the mean of its hours' means", () => {
|
||||
/**
|
||||
* Two days: one with a workout hour of twelve readings at 150 and three
|
||||
* resting hours of one reading at 60 (day value 82.5), one with a single
|
||||
* resting reading at 60. Every window weighs each day once: 71.25. The plain
|
||||
* mean over the seventeen readings would be about 123.5.
|
||||
*/
|
||||
function pulseRows() {
|
||||
const now = new Date();
|
||||
const midnight = (daysAgo: number) =>
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) -
|
||||
daysAgo * dayMs;
|
||||
const workout = midnight(2);
|
||||
const rows: Array<{ type: string; value: number; measuredAt: Date }> = [];
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
rows.push({
|
||||
type: "PULSE",
|
||||
value: 150,
|
||||
measuredAt: new Date(workout + 10 * 3_600_000 + i * 5 * 60_000),
|
||||
});
|
||||
}
|
||||
for (const h of [12, 14, 16]) {
|
||||
rows.push({
|
||||
type: "PULSE",
|
||||
value: 60,
|
||||
measuredAt: new Date(workout + h * 3_600_000),
|
||||
});
|
||||
}
|
||||
rows.push({
|
||||
type: "PULSE",
|
||||
value: 60,
|
||||
measuredAt: new Date(midnight(3) + 8 * 3_600_000),
|
||||
});
|
||||
// The bulk read is newest-first; the builder reverses it.
|
||||
return rows
|
||||
.map((r) => ({
|
||||
...r,
|
||||
sleepStage: null,
|
||||
source: "APPLE_HEALTH",
|
||||
deviceType: null,
|
||||
}))
|
||||
.sort((a, b) => b.measuredAt.getTime() - a.measuredAt.getTime());
|
||||
}
|
||||
|
||||
it("weighs each day once in every pulse window and in the all-time mean", async () => {
|
||||
prismaMock.measurement.findMany.mockResolvedValue(pulseRows());
|
||||
const f = await extractFeatures("user-pulse-day", false);
|
||||
expect(f.pulse?.avg7).toBe(71.25);
|
||||
expect(f.pulse?.avg30).toBe(71.25);
|
||||
expect(f.pulse?.avg90).toBe(71.25);
|
||||
expect(f.pulse?.allTimeAvg).toBe(71.25);
|
||||
// Extremes stay over every reading.
|
||||
expect(f.pulse?.allTimeMin).toBe(60);
|
||||
expect(f.pulse?.allTimeMax).toBe(150);
|
||||
expect(f.historicalComparison?.pulse?.current7dAvg).toBe(71.25);
|
||||
});
|
||||
});
|
||||
@@ -259,3 +259,52 @@ describe("generateGeneralStatusForUser — token-leak hardening (v1.4.27 F16)",
|
||||
expect(notes[0].text).not.toContain("metric:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateGeneralStatusForUser — a pulse day is the mean of its hours", () => {
|
||||
it("folds pulse days and buckets by hours and days, other types unchanged", async () => {
|
||||
// Yesterday: a workout hour of twelve readings at 150 and three resting
|
||||
// hours at 60 (day value 82.5); the day before one reading at 60. The
|
||||
// plain mean of yesterday's fifteen readings would be 132.
|
||||
const now = new Date();
|
||||
const midnight = (daysAgo: number) =>
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) -
|
||||
daysAgo * dayMs;
|
||||
const at = (daysAgo: number, h: number, m = 0) =>
|
||||
new Date(midnight(daysAgo) + h * 3_600_000 + m * 60_000);
|
||||
const rows: Array<{ type: string; value: number; measuredAt: Date }> = [];
|
||||
for (const type of ["PULSE", "WEIGHT"]) {
|
||||
for (let i = 0; i < 12; i++) {
|
||||
rows.push({ type, value: 150, measuredAt: at(1, 10, i * 5) });
|
||||
}
|
||||
for (const h of [12, 14, 16]) {
|
||||
rows.push({ type, value: 60, measuredAt: at(1, h) });
|
||||
}
|
||||
rows.push({ type, value: 60, measuredAt: at(2, 8) });
|
||||
}
|
||||
rows.sort((a, b) => b.measuredAt.getTime() - a.measuredAt.getTime());
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: null,
|
||||
timezone: "UTC",
|
||||
} as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue(rows as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
const captured: { userPrompt: string | null } = { userPrompt: null };
|
||||
stubCompletion('{"summary":"OK"}', captured);
|
||||
|
||||
await generateGeneralStatusForUser("user-pulse-hours", { locale: "en" });
|
||||
|
||||
const snapshot = JSON.parse(captured.userPrompt!.match(/\{[\s\S]*\}/)![0]);
|
||||
const pulse = snapshot.measurementSeries.PULSE;
|
||||
expect(pulse.summary.mean).toBe(71.25);
|
||||
expect(pulse.series.recent.map((d: { mean: number }) => d.mean)).toEqual([
|
||||
60, 82.5,
|
||||
]);
|
||||
const weight = snapshot.measurementSeries.WEIGHT;
|
||||
expect(weight.series.recent.map((d: { mean: number }) => d.mean)).toEqual([
|
||||
60, 132,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { crossMetricDailySeries } from "../mood-status";
|
||||
|
||||
/**
|
||||
* The mood correlations pair each mood day with that day's pulse. A pulse
|
||||
* day is the mean of its hours' means, so a workout hour of many readings does
|
||||
* not decide the value the mood is paired with.
|
||||
*/
|
||||
describe("crossMetricDailySeries", () => {
|
||||
const now = new Date("2026-03-05T12:00:00Z");
|
||||
const workoutDay = [
|
||||
...Array.from({ length: 12 }, (_, i) => ({
|
||||
type: "PULSE",
|
||||
value: 150,
|
||||
measuredAt: new Date(Date.UTC(2026, 2, 3, 10, i * 5)),
|
||||
})),
|
||||
...[12, 14, 16].map((h) => ({
|
||||
type: "PULSE",
|
||||
value: 60,
|
||||
measuredAt: new Date(Date.UTC(2026, 2, 3, h)),
|
||||
})),
|
||||
];
|
||||
|
||||
it("gives a pulse day the mean of its hours' means", () => {
|
||||
const series = crossMetricDailySeries(workoutDay, "PULSE", now, "UTC");
|
||||
expect(series.daily.map((d) => d.value)).toEqual([82.5]);
|
||||
// The reading count stays.
|
||||
expect(series.daily[0].n).toBe(15);
|
||||
});
|
||||
|
||||
it("keeps the plain daily mean for every other type", () => {
|
||||
const weight = workoutDay.map((m) => ({ ...m, type: "WEIGHT" }));
|
||||
const series = crossMetricDailySeries(weight, "WEIGHT", now, "UTC");
|
||||
expect(series.daily.map((d) => d.value)).toEqual([132]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* A day of pulse is the mean of its hours' means and a longer bucket the mean
|
||||
* of its days (`day-mean.ts`), in every in-memory fold the status cards and the
|
||||
* Coach snapshot build. One workout day (twelve readings at 150 in one hour,
|
||||
* three resting hours at 60: day value 82.5) and one resting day (one reading
|
||||
* at 60): a bucket holding both reads 71.25; the plain mean of the sixteen
|
||||
* readings is 127.5. Every other type keeps the plain mean, and the counts,
|
||||
* minima and maxima stay over the readings.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { bucketSeries } from "../bucket-series";
|
||||
import {
|
||||
buildGradedSeriesFromDayAggregates,
|
||||
buildGradedSeriesFromPoints,
|
||||
} from "../graded-series";
|
||||
import {
|
||||
bucketWeekly,
|
||||
buildDailyValueRows,
|
||||
} from "@/lib/ai/coach/snapshot-series";
|
||||
|
||||
const HOUR = 3_600_000;
|
||||
const DAY = 24 * HOUR;
|
||||
const TZ = "UTC";
|
||||
|
||||
function twoDays(firstDay: number) {
|
||||
const rows: Array<{ measuredAt: Date; value: number }> = [];
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
rows.push({
|
||||
measuredAt: new Date(firstDay + 10 * HOUR + i * 300_000),
|
||||
value: 150,
|
||||
});
|
||||
}
|
||||
for (const h of [12, 14, 16]) {
|
||||
rows.push({ measuredAt: new Date(firstDay + h * HOUR), value: 60 });
|
||||
}
|
||||
rows.push({ measuredAt: new Date(firstDay + DAY + 8 * HOUR), value: 60 });
|
||||
return rows;
|
||||
}
|
||||
|
||||
describe("graded series from points", () => {
|
||||
// Wednesday 2026-03-04 and Thursday 2026-03-05, both in ISO week 10.
|
||||
const first = Date.UTC(2026, 2, 4);
|
||||
const rows = twoDays(first);
|
||||
|
||||
it("gives a recent pulse day the mean of its hours", () => {
|
||||
const g = buildGradedSeriesFromPoints(
|
||||
rows,
|
||||
new Date(first + 2 * DAY),
|
||||
TZ,
|
||||
"PULSE",
|
||||
);
|
||||
const day = g.recent.find((d) => d.date === "2026-03-04")!;
|
||||
expect(day.mean).toBe(82.5);
|
||||
expect(day.n).toBe(15);
|
||||
expect(day.min).toBe(60);
|
||||
expect(day.max).toBe(150);
|
||||
});
|
||||
|
||||
it("gives a pulse week the mean of its days", () => {
|
||||
// 30 days later both days fall in the weekly slice.
|
||||
const g = buildGradedSeriesFromPoints(
|
||||
rows,
|
||||
new Date(first + 30 * DAY),
|
||||
TZ,
|
||||
"PULSE",
|
||||
);
|
||||
expect(g.weekly).toHaveLength(1);
|
||||
expect(g.weekly[0].mean).toBe(71.25);
|
||||
expect(g.weekly[0].n).toBe(16);
|
||||
});
|
||||
|
||||
it("keeps the plain mean for any other type", () => {
|
||||
const g = buildGradedSeriesFromPoints(
|
||||
rows,
|
||||
new Date(first + 30 * DAY),
|
||||
TZ,
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
);
|
||||
expect(g.weekly[0].mean).toBe(127.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("graded series from day aggregates", () => {
|
||||
it("takes a bucket's mean from the day weights when the rows carry them", () => {
|
||||
const g = buildGradedSeriesFromDayAggregates([
|
||||
{
|
||||
day: "2026-03-04",
|
||||
segment: 1,
|
||||
n: 15,
|
||||
sum: 12 * 150 + 3 * 60,
|
||||
min: 60,
|
||||
max: 150,
|
||||
weightedSum: 82.5,
|
||||
weightSum: 1,
|
||||
},
|
||||
{
|
||||
day: "2026-03-05",
|
||||
segment: 1,
|
||||
n: 1,
|
||||
sum: 60,
|
||||
min: 60,
|
||||
max: 60,
|
||||
weightedSum: 60,
|
||||
weightSum: 1,
|
||||
},
|
||||
]);
|
||||
expect(g.weekly[0].mean).toBe(71.25);
|
||||
expect(g.weekly[0].n).toBe(16);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bucketSeries", () => {
|
||||
const now = new Date(Date.UTC(2026, 2, 6, 12));
|
||||
const rows = twoDays(Date.UTC(2026, 2, 4));
|
||||
|
||||
it("gives a pulse day its hours' mean and keeps n the readings", () => {
|
||||
const s = bucketSeries(rows, { now, tz: TZ, type: "PULSE" });
|
||||
const workout = s.daily.find((d) => d.dayOffset === 2)!;
|
||||
expect(workout.value).toBe(82.5);
|
||||
expect(workout.n).toBe(15);
|
||||
});
|
||||
|
||||
it("gives a pulse month the mean of its days", () => {
|
||||
const s = bucketSeries(rows, {
|
||||
now: new Date(Date.UTC(2026, 2, 4) + 20 * DAY),
|
||||
tz: TZ,
|
||||
type: "PULSE",
|
||||
dailyDays: 10,
|
||||
});
|
||||
expect(s.monthly).toHaveLength(1);
|
||||
expect(s.monthly[0].value).toBe(71.25);
|
||||
expect(s.monthly[0].n).toBe(16);
|
||||
});
|
||||
|
||||
it("keeps the plain mean without a type or for any other type", () => {
|
||||
expect(
|
||||
bucketSeries(rows, { now, tz: TZ }).daily.find((d) => d.dayOffset === 2)!
|
||||
.value,
|
||||
).toBe(132);
|
||||
expect(
|
||||
bucketSeries(rows, { now, tz: TZ, type: "WEIGHT" }).daily.find(
|
||||
(d) => d.dayOffset === 2,
|
||||
)!.value,
|
||||
).toBe(132);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Coach snapshot timeline", () => {
|
||||
const first = Date.UTC(2026, 2, 4);
|
||||
const rows = twoDays(first);
|
||||
|
||||
it("gives a recent pulse day the mean of its hours", () => {
|
||||
const daily = buildDailyValueRows(rows, new Date(first), TZ, "PULSE");
|
||||
expect(daily.map((d) => d.value)).toEqual([82.5, 60]);
|
||||
expect(
|
||||
buildDailyValueRows(rows, new Date(first), TZ).map((d) => d.value),
|
||||
).toEqual([132, 60]);
|
||||
});
|
||||
|
||||
it("gives a pulse week the mean of its days, count the readings", () => {
|
||||
const [week] = bucketWeekly(rows, TZ, "PULSE");
|
||||
expect(week.mean).toBe(71.3);
|
||||
expect(week.count).toBe(16);
|
||||
expect(bucketWeekly(rows, TZ)[0].mean).toBe(127.5);
|
||||
});
|
||||
});
|
||||
@@ -347,3 +347,52 @@ describe("generatePulseStatusForUser — token-leak hardening (v1.4.27 F16)", ()
|
||||
expect(notes[0].text).toContain("Your pulse is stable.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generatePulseStatusForUser — a pulse day is the mean of its hours", () => {
|
||||
it("gives the latest day and the summary the hours' and days' means", async () => {
|
||||
// Yesterday: a workout hour of twelve readings at 150 and three resting
|
||||
// hours at 60 (day value 82.5). The day before: one reading at 60. The
|
||||
// plain mean of yesterday's fifteen readings would be 132.
|
||||
const now = new Date();
|
||||
const midnight = (daysAgo: number) =>
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) -
|
||||
daysAgo * dayMs;
|
||||
const records: Array<{ value: number; measuredAt: Date }> = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
records.push({
|
||||
value: 150,
|
||||
measuredAt: new Date(midnight(1) + 10 * 3_600_000 + i * 300_000),
|
||||
});
|
||||
}
|
||||
for (const h of [12, 14, 16]) {
|
||||
records.push({
|
||||
value: 60,
|
||||
measuredAt: new Date(midnight(1) + h * 3_600_000),
|
||||
});
|
||||
}
|
||||
records.push({
|
||||
value: 60,
|
||||
measuredAt: new Date(midnight(2) + 8 * 3_600_000),
|
||||
});
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
timezone: "UTC",
|
||||
} as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockImplementation(((args: {
|
||||
where: { type: string };
|
||||
}) =>
|
||||
Promise.resolve(
|
||||
args.where.type === "PULSE" ? [...records].reverse() : [],
|
||||
)) as never);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
const captured: { userPrompt: string | null } = { userPrompt: null };
|
||||
stubCompletion('{"summary":"OK"}', captured);
|
||||
|
||||
await generatePulseStatusForUser("user-pulse-hours", { locale: "en" });
|
||||
|
||||
const snapshot = JSON.parse(captured.userPrompt!.match(/\{[\s\S]*\}/)![0]);
|
||||
expect(snapshot.pulse.latestDayFocus.value).toBe(82.5);
|
||||
expect(snapshot.pulse.summary.mean).toBe(71.25);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { computeSignalsOfDay } from "../signals-of-day";
|
||||
|
||||
const DAY = 86_400_000;
|
||||
|
||||
/**
|
||||
* A day of pulse is the mean of its hours' means and a window the mean of its
|
||||
* days (`day-mean.ts`). Two days: a workout hour of twelve readings at 150 and
|
||||
* three resting hours at 60 (day value 82.5), then a day with one reading at
|
||||
* 60. The 7- and 30-day means are 71.25, not the 127.5 of the readings.
|
||||
*/
|
||||
function rows(now: number) {
|
||||
const today = new Date(now);
|
||||
const midnight = (daysAgo: number) =>
|
||||
Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()) -
|
||||
daysAgo * DAY;
|
||||
const out: Array<{ value: number; measuredAt: Date }> = [];
|
||||
const workout = midnight(3);
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
out.push({
|
||||
value: 150,
|
||||
measuredAt: new Date(workout + 10 * 3_600_000 + i * 300_000),
|
||||
});
|
||||
}
|
||||
for (const h of [12, 14, 16]) {
|
||||
out.push({ value: 60, measuredAt: new Date(workout + h * 3_600_000) });
|
||||
}
|
||||
out.push({ value: 60, measuredAt: new Date(midnight(2) + 8 * 3_600_000) });
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("the pulse signal of the day", () => {
|
||||
it("weighs each day once in its 7- and 30-day means", () => {
|
||||
const now = Date.now();
|
||||
const pulse = rows(now);
|
||||
const [signal] = computeSignalsOfDay(
|
||||
(type) => (type === "PULSE" ? pulse : []),
|
||||
now,
|
||||
"UTC",
|
||||
);
|
||||
expect(signal.metric).toBe("pulse");
|
||||
expect(signal.avg7).toBe(71.25);
|
||||
expect(signal.avg30).toBe(71.25);
|
||||
expect(signal.deltaVs7).toBe(-11.25);
|
||||
});
|
||||
|
||||
it("leaves every other metric on the plain mean of its readings", () => {
|
||||
const now = Date.now();
|
||||
const weight = rows(now);
|
||||
const [signal] = computeSignalsOfDay(
|
||||
(type) => (type === "WEIGHT" ? weight : []),
|
||||
now,
|
||||
"UTC",
|
||||
);
|
||||
expect(signal.avg7).toBe(127.5);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,8 @@
|
||||
*/
|
||||
|
||||
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
|
||||
import { readingsMean } from "@/lib/measurements/day-mean";
|
||||
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -61,6 +63,13 @@ export interface BucketOptions {
|
||||
* Berlin; this threads the user TZ the display surfaces already use.
|
||||
*/
|
||||
tz?: string;
|
||||
/**
|
||||
* The measurement type, when known. For a type whose day is the mean of its
|
||||
* hours' means (pulse, `day-mean.ts`) a daily bucket's value is that day
|
||||
* value and a monthly bucket's the mean of its days; `n` stays the number
|
||||
* of readings.
|
||||
*/
|
||||
type?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,6 +153,26 @@ export function bucketSeries(
|
||||
|
||||
const daily = new Map<number, { sum: number; n: number }>();
|
||||
const monthly = new Map<number, { sum: number; n: number }>();
|
||||
// An hourly-mean type keeps each bucket's readings for its mean of days.
|
||||
const hourly = options.type !== undefined && usesHourlyMeanDay(options.type);
|
||||
const dailyRows = new Map<
|
||||
number,
|
||||
Array<{ measuredAt: Date; value: number }>
|
||||
>();
|
||||
const monthlyRows = new Map<
|
||||
number,
|
||||
Array<{ measuredAt: Date; value: number }>
|
||||
>();
|
||||
const keep = (
|
||||
rows: Map<number, Array<{ measuredAt: Date; value: number }>>,
|
||||
key: number,
|
||||
record: { measuredAt: Date; value: number },
|
||||
) => {
|
||||
if (!hourly) return;
|
||||
const list = rows.get(key);
|
||||
if (list) list.push(record);
|
||||
else rows.set(key, [record]);
|
||||
};
|
||||
|
||||
// monthOffset 12 → first 30-day window past the daily horizon. Keeping
|
||||
// the labelling stable regardless of `dailyDays` so prompts stay
|
||||
@@ -160,6 +189,7 @@ export function bucketSeries(
|
||||
bucket.sum += record.value;
|
||||
bucket.n += 1;
|
||||
daily.set(offset, bucket);
|
||||
keep(dailyRows, offset, record);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -170,13 +200,24 @@ export function bucketSeries(
|
||||
bucket.sum += record.value;
|
||||
bucket.n += 1;
|
||||
monthly.set(monthOffset, bucket);
|
||||
keep(monthlyRows, monthOffset, record);
|
||||
}
|
||||
const meanOf = (
|
||||
rows: Map<number, Array<{ measuredAt: Date; value: number }>>,
|
||||
key: number,
|
||||
agg: { sum: number; n: number },
|
||||
) => {
|
||||
const list = rows.get(key);
|
||||
return list
|
||||
? (readingsMean(options.type!, list, tz) ?? agg.sum / agg.n)
|
||||
: agg.sum / agg.n;
|
||||
};
|
||||
|
||||
const dailyOut: DailyBucket[] = Array.from(daily.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([dayOffset, agg]) => ({
|
||||
dayOffset,
|
||||
value: round(agg.sum / agg.n),
|
||||
value: round(meanOf(dailyRows, dayOffset, agg)),
|
||||
n: agg.n,
|
||||
}));
|
||||
|
||||
@@ -184,7 +225,7 @@ export function bucketSeries(
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([monthOffset, agg]) => ({
|
||||
monthOffset,
|
||||
value: round(agg.sum / agg.n),
|
||||
value: round(meanOf(monthlyRows, monthOffset, agg)),
|
||||
n: agg.n,
|
||||
}));
|
||||
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql";
|
||||
import {
|
||||
dayWeightedRowsSql,
|
||||
foldMeanSql,
|
||||
isHourlyMeanTypeSql,
|
||||
} from "@/lib/measurements/day-mean";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { classifyAgainstEffectiveRange } from "@/lib/labs/reference-range";
|
||||
import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day";
|
||||
@@ -64,10 +69,41 @@ export async function readAllTimeExtremes(
|
||||
const typeList = Prisma.join(
|
||||
types.map((t) => Prisma.sql`${t}::measurement_type`),
|
||||
);
|
||||
// A day of pulse is the mean of its hours' means and the full history the
|
||||
// mean of its days (`day-mean.ts`). `weighted` carries, per source and day,
|
||||
// the day-weighted sum and the day's weight (one) for those types only;
|
||||
// every other type keeps the reading-weighted mean below, unchanged.
|
||||
const mean = Prisma.raw(
|
||||
foldMeanSql({
|
||||
typeColumn: 'c."type"',
|
||||
total: "c.total",
|
||||
count: "c.cnt",
|
||||
weightedSum: "c.wsum",
|
||||
weightSum: "c.wdays",
|
||||
}),
|
||||
);
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{ type: string; mean: number; min: number; max: number }>
|
||||
>`
|
||||
WITH per_source AS (
|
||||
WITH hourly_src AS (
|
||||
SELECT m.*
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."type" IN (${typeList})
|
||||
AND ${Prisma.raw(isHourlyMeanTypeSql('m."type"'))}
|
||||
),
|
||||
weighted AS (
|
||||
SELECT
|
||||
w."type",
|
||||
w."source",
|
||||
date_trunc('day', w."measured_at") AS day,
|
||||
SUM(w."value" * w.day_weight)::double precision AS wsum,
|
||||
SUM(w.day_weight)::double precision AS wdays
|
||||
FROM ${dayWeightedRowsSql("hourly_src", null, { bySource: true })} w
|
||||
GROUP BY w."type", w."source", 3
|
||||
),
|
||||
per_source AS (
|
||||
SELECT
|
||||
m."type",
|
||||
m."source",
|
||||
@@ -84,13 +120,15 @@ export async function readAllTimeExtremes(
|
||||
),
|
||||
canon AS (
|
||||
SELECT DISTINCT ON (p."type", p.day)
|
||||
p."type", p.cnt, p.total, p.min_value, p.max_value
|
||||
p."type", p.cnt, p.total, p.min_value, p.max_value, w.wsum, w.wdays
|
||||
FROM per_source p
|
||||
LEFT JOIN weighted w
|
||||
ON w."type" = p."type" AND w."source" = p."source" AND w.day = p.day
|
||||
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,
|
||||
${mean}::double precision AS mean,
|
||||
MIN(c.min_value)::double precision AS min,
|
||||
MAX(c.max_value)::double precision AS max
|
||||
FROM canon c
|
||||
|
||||
@@ -14,7 +14,9 @@ import {
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
|
||||
import { readingsMean } from "@/lib/measurements/day-mean";
|
||||
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
|
||||
import { isCurrentForTodayClaim } from "@/lib/insights/measurement-freshness";
|
||||
import { computeMoodDimensionSeries } from "@/lib/insights/mood-dimension-series";
|
||||
import { computeContextMoodComparison } from "@/lib/insights/mood-context-crosstab";
|
||||
@@ -914,12 +916,19 @@ function computeCoverage(
|
||||
};
|
||||
}
|
||||
|
||||
/** Compute average of values within a time window (days ago from now). */
|
||||
/**
|
||||
* Compute average of values within a time window (days ago from now).
|
||||
*
|
||||
* `day` names the type and the zone its days are read in. For a type whose
|
||||
* day is the mean of its hours' means (pulse, see `day-mean.ts`) the window
|
||||
* is then the mean of its day values; every other type keeps the plain mean.
|
||||
*/
|
||||
export function avgInWindow(
|
||||
records: Array<{ value: number; measuredAt: Date }>,
|
||||
now: number,
|
||||
fromDaysAgo: number,
|
||||
toDaysAgo: number = 0,
|
||||
day?: { type: string; tz: string },
|
||||
): number | null {
|
||||
const fromMs = now - fromDaysAgo * 24 * 60 * 60 * 1000;
|
||||
const toMs = now - toDaysAgo * 24 * 60 * 60 * 1000;
|
||||
@@ -928,6 +937,10 @@ export function avgInWindow(
|
||||
return t >= fromMs && t <= toMs;
|
||||
});
|
||||
if (filtered.length === 0) return null;
|
||||
if (day && usesHourlyMeanDay(day.type)) {
|
||||
const mean = readingsMean(day.type, filtered, day.tz);
|
||||
return mean === null ? null : Math.round(mean * 100) / 100;
|
||||
}
|
||||
const sum = filtered.reduce((s, r) => s + r.value, 0);
|
||||
return Math.round((sum / filtered.length) * 100) / 100;
|
||||
}
|
||||
@@ -1033,6 +1046,10 @@ export async function extractFeatures(
|
||||
: null;
|
||||
|
||||
const byType = (type: string) => measurements.filter((m) => m.type === type);
|
||||
// The zone pulse days are read in; resolved only when there is pulse to read.
|
||||
const pulseTz = measurements.some((m) => m.type === "PULSE")
|
||||
? await resolveUserTimezone(userId)
|
||||
: DEFAULT_TIMEZONE;
|
||||
|
||||
const bpTargets = getBpTargets(user?.dateOfBirth ?? null);
|
||||
|
||||
@@ -1231,16 +1248,23 @@ export async function extractFeatures(
|
||||
const pulseData = byType("PULSE");
|
||||
if (pulseData.length > 0) {
|
||||
const summary = summarize(toDataPoints(pulseData));
|
||||
// A day of pulse is the mean of its local hours' means and a window the
|
||||
// mean of its days (see `day-mean.ts`): `summarize` means every reading,
|
||||
// so the means come from the shared helper over the same windows.
|
||||
const pulseDays = { type: "PULSE", tz: pulseTz };
|
||||
const pulseMean = (fromDaysAgo: number) =>
|
||||
avgInWindow(pulseData, now, fromDaysAgo, 0, pulseDays);
|
||||
const allPulse = readingsMean("PULSE", pulseData, pulseTz);
|
||||
features.pulse = {
|
||||
avg7: summary.avg7,
|
||||
avg30: summary.avg30,
|
||||
avg90: avgInWindow(pulseData, now, 90),
|
||||
avg7: pulseMean(7),
|
||||
avg30: pulseMean(30),
|
||||
avg90: pulseMean(90),
|
||||
// v1.18.11 P1 — full-history extremes when the bulk read is windowed.
|
||||
allTimeAvg: allTimeExtremes
|
||||
? roundMean(allTimeExtremes.get("PULSE")?.mean ?? null)
|
||||
: summary.count > 0
|
||||
? summary.mean
|
||||
: null,
|
||||
: allPulse === null
|
||||
? null
|
||||
: Math.round(allPulse * 100) / 100,
|
||||
allTimeMin: allTimeExtremes
|
||||
? (allTimeExtremes.get("PULSE")?.min ?? null)
|
||||
: summary.count > 0
|
||||
@@ -1650,6 +1674,7 @@ export async function extractFeatures(
|
||||
features.historicalComparison.pulse = computeHistoricalComparison(
|
||||
pulseData,
|
||||
now,
|
||||
{ type: "PULSE", tz: pulseTz },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1768,7 +1793,7 @@ export async function extractFeatures(
|
||||
// in-memory measurement set (no extra DB round-trip). Lands inside the
|
||||
// compacted features payload, so it feeds both the briefing prompt AND the
|
||||
// content-hash gate — a fresh daily signal forces the briefing to refresh.
|
||||
const signalsOfDay = computeSignalsOfDay(byType, now);
|
||||
const signalsOfDay = computeSignalsOfDay(byType, now, pulseTz);
|
||||
if (signalsOfDay.length > 0) {
|
||||
features.signalsOfDay = signalsOfDay;
|
||||
}
|
||||
|
||||
@@ -216,9 +216,11 @@ export async function prepareGeneralStatusForUser(
|
||||
}));
|
||||
if (records.length === 0) return [];
|
||||
|
||||
const series = applyPayloadBudget(records, { now, tz: userTz });
|
||||
// `type` makes a pulse day the mean of its hours' means and a pulse
|
||||
// window the mean of its days (`day-mean.ts`); other types unchanged.
|
||||
const series = applyPayloadBudget(records, { now, tz: userTz, type });
|
||||
dailyByType.set(type, series);
|
||||
const graded = buildGradedSeriesFromPoints(records, now, userTz);
|
||||
const graded = buildGradedSeriesFromPoints(records, now, userTz, type);
|
||||
|
||||
return [
|
||||
[
|
||||
|
||||
@@ -57,6 +57,8 @@ import {
|
||||
readDayAggregates,
|
||||
type DayAggregateRow,
|
||||
} from "@/lib/measurements/day-aggregates";
|
||||
import { readingsMean } from "@/lib/measurements/day-mean";
|
||||
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
@@ -169,6 +171,20 @@ interface Agg {
|
||||
min: number;
|
||||
max: number;
|
||||
n: number;
|
||||
/**
|
||||
* Day-weighted sums for a type whose day is the mean of its hours' means
|
||||
* (`DayAggregateRow.weightedSum` / `weightSum`); the bucket mean is then
|
||||
* their ratio, the mean of its days. Absent for every other type.
|
||||
*/
|
||||
ws?: number;
|
||||
wd?: number;
|
||||
}
|
||||
|
||||
/** A bucket's mean: of its days for an hourly-mean type, of its readings otherwise. */
|
||||
function aggMean(a: Agg): number {
|
||||
return a.wd !== undefined && a.wd > 0 && a.ws !== undefined
|
||||
? a.ws / a.wd
|
||||
: a.sum / a.n;
|
||||
}
|
||||
|
||||
function foldInto(map: Map<string, Agg>, key: string, value: number): void {
|
||||
@@ -214,8 +230,24 @@ export function buildGradedSeriesFromPoints(
|
||||
points: Point[],
|
||||
now: Date,
|
||||
timeZone: string,
|
||||
/**
|
||||
* The measurement type, when known. For a type whose day is the mean of its
|
||||
* hours' means (pulse, `day-mean.ts`) every bucket's mean is the mean of its
|
||||
* days; min, max and n stay over the readings.
|
||||
*/
|
||||
type?: string,
|
||||
): GradedSeries {
|
||||
const nowMs = now.getTime();
|
||||
const hourly = type !== undefined && usesHourlyMeanDay(type);
|
||||
const members = new Map<Agg, Point[]>();
|
||||
const foldPoint = (map: Map<string, Agg>, key: string, p: Point) => {
|
||||
foldInto(map, key, p.value);
|
||||
if (!hourly) return;
|
||||
const agg = map.get(key)!;
|
||||
const list = members.get(agg);
|
||||
if (list) list.push(p);
|
||||
else members.set(agg, [p]);
|
||||
};
|
||||
|
||||
const recentAgg = new Map<string, Agg>();
|
||||
const weeklyAgg = new Map<string, Agg>();
|
||||
@@ -231,26 +263,31 @@ export function buildGradedSeriesFromPoints(
|
||||
// Every bucket is a period of the user's own calendar.
|
||||
const { year, month, day } = localYmd(p.measuredAt, timeZone);
|
||||
if (ageMs < RECENT_WINDOW_MS) {
|
||||
foldInto(recentAgg, `${year}-${month}-${day}`, p.value);
|
||||
foldPoint(recentAgg, `${year}-${month}-${day}`, p);
|
||||
} else if (ageMs < WEEKLY_WINDOW_MS) {
|
||||
foldInto(weeklyAgg, isoWeekKeyOfYmd(year, month, day), p.value);
|
||||
foldPoint(weeklyAgg, isoWeekKeyOfYmd(year, month, day), p);
|
||||
} else if (ageMs < MONTHLY_WINDOW_MS) {
|
||||
foldInto(monthlyAgg, `${year}-${month}`, p.value);
|
||||
foldPoint(monthlyAgg, `${year}-${month}`, p);
|
||||
} else {
|
||||
const yk = year;
|
||||
foldInto(yearlyAgg, yk, p.value);
|
||||
foldPoint(yearlyAgg, yk, p);
|
||||
const list = yearlyValues.get(yk);
|
||||
if (list) list.push(p.value);
|
||||
else yearlyValues.set(yk, [p.value]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [agg, list] of members) {
|
||||
agg.ws = readingsMean(type!, list, timeZone) ?? agg.sum / agg.n;
|
||||
agg.wd = 1;
|
||||
}
|
||||
|
||||
const recent: RecentDayBucket[] = Array.from(recentAgg.entries())
|
||||
.map(([date, a]) => ({
|
||||
date,
|
||||
min: round(a.min),
|
||||
max: round(a.max),
|
||||
mean: round(a.sum / a.n),
|
||||
mean: round(aggMean(a)),
|
||||
n: a.n,
|
||||
}))
|
||||
.sort((x, y) => x.date.localeCompare(y.date));
|
||||
@@ -260,7 +297,7 @@ export function buildGradedSeriesFromPoints(
|
||||
weekISO,
|
||||
min: round(a.min),
|
||||
max: round(a.max),
|
||||
mean: round(a.sum / a.n),
|
||||
mean: round(aggMean(a)),
|
||||
n: a.n,
|
||||
}))
|
||||
.sort((x, y) => x.weekISO.localeCompare(y.weekISO));
|
||||
@@ -270,7 +307,7 @@ export function buildGradedSeriesFromPoints(
|
||||
month,
|
||||
min: round(a.min),
|
||||
max: round(a.max),
|
||||
mean: round(a.sum / a.n),
|
||||
mean: round(aggMean(a)),
|
||||
n: a.n,
|
||||
}))
|
||||
.sort((x, y) => x.month.localeCompare(y.month));
|
||||
@@ -280,7 +317,7 @@ export function buildGradedSeriesFromPoints(
|
||||
year,
|
||||
min: round(a.min),
|
||||
max: round(a.max),
|
||||
mean: round(a.sum / a.n),
|
||||
mean: round(aggMean(a)),
|
||||
n: a.n,
|
||||
// Order the readings oldest → newest for the slope. The map kept
|
||||
// insertion order; `points` arrives caller-ordered, so re-sort by
|
||||
@@ -304,8 +341,20 @@ function mergeInto(
|
||||
existing.n += row.n;
|
||||
if (row.min < existing.min) existing.min = row.min;
|
||||
if (row.max > existing.max) existing.max = row.max;
|
||||
if (row.weightedSum !== undefined && row.weightSum !== undefined) {
|
||||
existing.ws = (existing.ws ?? 0) + row.weightedSum;
|
||||
existing.wd = (existing.wd ?? 0) + row.weightSum;
|
||||
}
|
||||
} else {
|
||||
map.set(key, { sum: row.sum, min: row.min, max: row.max, n: row.n });
|
||||
map.set(key, {
|
||||
sum: row.sum,
|
||||
min: row.min,
|
||||
max: row.max,
|
||||
n: row.n,
|
||||
...(row.weightedSum !== undefined && row.weightSum !== undefined
|
||||
? { ws: row.weightedSum, wd: row.weightSum }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,7 +408,9 @@ async function readGradedDayAggregates(
|
||||
* The graded series folded from per-day aggregates (segment = age class).
|
||||
*
|
||||
* recent / weekly / monthly and every bucket's min / max / mean / n match
|
||||
* the raw-row fold exactly. The yearly slope is taken over the day means in
|
||||
* the raw-row fold exactly, except that a type whose day is the mean of its
|
||||
* hours' means (pulse, `day-mean.ts`) takes each bucket's mean over its days
|
||||
* (the rows' `weightedSum` / `weightSum`), each day the mean of its hours. The yearly slope is taken over the day means in
|
||||
* date order rather than over individual readings: a direction signal either
|
||||
* way, and the only form that stays bounded on a dense stream.
|
||||
*/
|
||||
@@ -386,7 +437,10 @@ export function buildGradedSeriesFromDayAggregates(
|
||||
} else {
|
||||
mergeInto(yearlyAgg, year, row);
|
||||
const list = yearlyValues.get(year);
|
||||
const dayMean = row.sum / row.n;
|
||||
const dayMean =
|
||||
row.weightedSum !== undefined && row.weightSum
|
||||
? row.weightedSum / row.weightSum
|
||||
: row.sum / row.n;
|
||||
if (list) list.push(dayMean);
|
||||
else yearlyValues.set(year, [dayMean]);
|
||||
}
|
||||
@@ -398,7 +452,7 @@ export function buildGradedSeriesFromDayAggregates(
|
||||
key,
|
||||
min: round(a.min),
|
||||
max: round(a.max),
|
||||
mean: round(a.sum / a.n),
|
||||
mean: round(aggMean(a)),
|
||||
n: a.n,
|
||||
}))
|
||||
.sort((x, y) => x.key.localeCompare(y.key));
|
||||
|
||||
@@ -93,6 +93,30 @@ function stripFactorCrosstab(
|
||||
return rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* One cross metric's daily buckets for the mood correlations. The type is
|
||||
* passed through, so a pulse day is the mean of its hours' means (see
|
||||
* `day-mean.ts`) and a workout hour does not decide the day the mood is
|
||||
* paired with; every other type keeps its plain daily mean.
|
||||
*/
|
||||
export function crossMetricDailySeries(
|
||||
measurements: ReadonlyArray<{
|
||||
type: string;
|
||||
measuredAt: Date;
|
||||
value: number;
|
||||
}>,
|
||||
type: string,
|
||||
now: Date,
|
||||
tz: string,
|
||||
): ReturnType<typeof applyPayloadBudget> {
|
||||
return applyPayloadBudget(
|
||||
measurements
|
||||
.filter((m) => m.type === type)
|
||||
.map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
|
||||
{ now, tz, type },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public entry — unchanged signature. Prepares the card (cache-read,
|
||||
* read-only miss, snapshot build, hash gate) then, when the LLM is still
|
||||
@@ -389,25 +413,23 @@ export async function prepareMoodStatusForUser(
|
||||
annotate({ meta: { cross_metric_truncated: true } });
|
||||
}
|
||||
|
||||
const weightSeries = applyPayloadBudget(
|
||||
measurements
|
||||
.filter((m) => m.type === "WEIGHT")
|
||||
.map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
|
||||
{ now, tz: userTz },
|
||||
const weightSeries = crossMetricDailySeries(
|
||||
measurements,
|
||||
"WEIGHT",
|
||||
now,
|
||||
userTz,
|
||||
);
|
||||
|
||||
const sysSeries = applyPayloadBudget(
|
||||
measurements
|
||||
.filter((m) => m.type === "BLOOD_PRESSURE_SYS")
|
||||
.map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
|
||||
{ now, tz: userTz },
|
||||
const sysSeries = crossMetricDailySeries(
|
||||
measurements,
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
now,
|
||||
userTz,
|
||||
);
|
||||
|
||||
const pulseSeries = applyPayloadBudget(
|
||||
measurements
|
||||
.filter((m) => m.type === "PULSE")
|
||||
.map((m) => ({ measuredAt: m.measuredAt, value: m.value })),
|
||||
{ now, tz: userTz },
|
||||
const pulseSeries = crossMetricDailySeries(
|
||||
measurements,
|
||||
"PULSE",
|
||||
now,
|
||||
userTz,
|
||||
);
|
||||
|
||||
// Correlations between mood and other metrics — pair on dayOffset so
|
||||
|
||||
@@ -229,7 +229,12 @@ export async function preparePulseStatusForUser(
|
||||
// `applyPayloadBudget` daily buckets still drive the derived stats
|
||||
// below (latest, in-target %, delta). They are NOT embedded in the
|
||||
// prompt — the compact graded series replaces the full daily array.
|
||||
const pulseSeries = applyPayloadBudget(pulsePoints, { now, tz: userTz });
|
||||
// A pulse day is the mean of its hours' means (`day-mean.ts`).
|
||||
const pulseSeries = applyPayloadBudget(pulsePoints, {
|
||||
now,
|
||||
tz: userTz,
|
||||
type: "PULSE",
|
||||
});
|
||||
// Primary metric: recent / weekly fold from the bounded raw read, the
|
||||
// monthly / yearly tail comes from the MONTH / YEAR rollup tier (with
|
||||
// a full-history in-memory fallback on a cold-tier coverage miss).
|
||||
|
||||
@@ -54,13 +54,14 @@ export interface SignalOfDay {
|
||||
export function computeHistoricalComparison(
|
||||
records: Array<{ value: number; measuredAt: Date }>,
|
||||
now: number,
|
||||
day?: { type: string; tz: string },
|
||||
): {
|
||||
current7dAvg: number | null;
|
||||
previous30dAvg: number | null;
|
||||
change: number | null;
|
||||
} {
|
||||
const current7dAvg = avgInWindow(records, now, 7, 0);
|
||||
const previous30dAvg = avgInWindow(records, now, 37, 7);
|
||||
const current7dAvg = avgInWindow(records, now, 7, 0, day);
|
||||
const previous30dAvg = avgInWindow(records, now, 37, 7, day);
|
||||
const change =
|
||||
current7dAvg !== null && previous30dAvg !== null
|
||||
? Math.round((current7dAvg - previous30dAvg) * 100) / 100
|
||||
@@ -98,6 +99,7 @@ function buildSignal(
|
||||
records: Array<{ value: number; measuredAt: Date }>,
|
||||
now: number,
|
||||
unit?: string,
|
||||
day?: { type: string; tz: string },
|
||||
): SignalOfDay | null {
|
||||
if (records.length === 0) return null;
|
||||
const newest = records[records.length - 1];
|
||||
@@ -112,8 +114,10 @@ function buildSignal(
|
||||
);
|
||||
if (win30.length < 3) return null;
|
||||
|
||||
const avg7 = avgInWindow(records, now, 7);
|
||||
const avg30 = avgInWindow(records, now, 30);
|
||||
// For pulse each window is the mean of its day values (`day-mean.ts`); the
|
||||
// spread and the anomaly stay over the readings.
|
||||
const avg7 = avgInWindow(records, now, 7, 0, day);
|
||||
const avg30 = avgInWindow(records, now, 30, 0, day);
|
||||
const spread30 = stdDev(win30.map((rec) => rec.value));
|
||||
const deltaVs7 = avg7 !== null ? r2(newest.value - avg7) : null;
|
||||
const deltaVs30 = avg30 !== null ? r2(newest.value - avg30) : null;
|
||||
@@ -186,6 +190,8 @@ function buildSignal(
|
||||
export function computeSignalsOfDay(
|
||||
byType: (type: string) => Array<{ value: number; measuredAt: Date }>,
|
||||
now: number,
|
||||
/** The zone pulse days are read in (`day-mean.ts`). */
|
||||
pulseTz: string = "UTC",
|
||||
): SignalOfDay[] {
|
||||
const candidates: SignalOfDay[] = [];
|
||||
const push = (s: SignalOfDay | null) => {
|
||||
@@ -213,7 +219,12 @@ export function computeSignalsOfDay(
|
||||
"bpm",
|
||||
),
|
||||
);
|
||||
push(buildSignal("pulse", "pulse", byType("PULSE"), now, "bpm"));
|
||||
push(
|
||||
buildSignal("pulse", "pulse", byType("PULSE"), now, "bpm", {
|
||||
type: "PULSE",
|
||||
tz: pulseTz,
|
||||
}),
|
||||
);
|
||||
// Weight and glucose are canonical here and carry no unit: the reader's
|
||||
// unit is attached where the signal is converted for the prompt
|
||||
// (`featuresInReaderUnits`), so no canonical symbol can leak into one.
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* The full-history pulse mean the insight features carry (`allTimeAvg`) is the
|
||||
* mean of the day values, each day the mean of its hours' means, like every
|
||||
* other pulse mean (`day-mean.ts`). Count-weighted, a workout day's dense hour
|
||||
* outweighed every resting hour of every other day.
|
||||
*
|
||||
* Two days: a workout hour of twelve readings at 150 and three resting hours
|
||||
* at 60 (day value 82.5), and a day with one reading at 60: 71.25. The same
|
||||
* readings as systolic pressure keep the plain mean (127.5) as the control.
|
||||
*/
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { readAllTimeExtremes } from "@/lib/insights/feature-blocks";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const USER = "feature-blocks-pulse-day";
|
||||
const DAY = 86_400_000;
|
||||
const now = new Date();
|
||||
const midnight = (daysAgo: number) =>
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) -
|
||||
daysAgo * DAY;
|
||||
|
||||
function readings(
|
||||
type: "PULSE" | "BLOOD_PRESSURE_SYS",
|
||||
unit: string,
|
||||
): Prisma.MeasurementCreateManyInput[] {
|
||||
const workout = midnight(40);
|
||||
const at: Array<[number, number]> = [];
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
at.push([workout + 10 * 3_600_000 + i * 300_000, 150]);
|
||||
}
|
||||
for (const h of [12, 14, 16]) at.push([workout + h * 3_600_000, 60]);
|
||||
at.push([midnight(39) + 8 * 3_600_000, 60]);
|
||||
return at.map(([ms, value], i) => ({
|
||||
id: `${USER}-${type}-${i}`,
|
||||
userId: USER,
|
||||
type,
|
||||
unit,
|
||||
source: "APPLE_HEALTH",
|
||||
value,
|
||||
measuredAt: new Date(ms),
|
||||
}));
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.user.create({ data: { id: USER, username: USER } });
|
||||
await prisma.measurement.createMany({
|
||||
data: [
|
||||
...readings("PULSE", "bpm"),
|
||||
...readings("BLOOD_PRESSURE_SYS", "mmHg"),
|
||||
],
|
||||
});
|
||||
}, 120_000);
|
||||
|
||||
describe("readAllTimeExtremes", () => {
|
||||
it("gives pulse the mean of its days, each the mean of its hours", async () => {
|
||||
const out = await readAllTimeExtremes(USER, [
|
||||
"PULSE",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
]);
|
||||
const pulse = out.get("PULSE");
|
||||
expect(pulse?.mean).toBeCloseTo(71.25, 9);
|
||||
// The extremes stay over every reading.
|
||||
expect(pulse?.min).toBe(60);
|
||||
expect(pulse?.max).toBe(150);
|
||||
});
|
||||
|
||||
it("keeps the plain mean of the readings for every other type", async () => {
|
||||
const out = await readAllTimeExtremes(USER, ["BLOOD_PRESSURE_SYS"]);
|
||||
expect(out.get("BLOOD_PRESSURE_SYS")?.mean).toBe(127.5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* The pulse status card's graded series reads per-day aggregates folded in
|
||||
* Postgres (`readDayAggregates`). For pulse those rows carry the day weights
|
||||
* of `day-mean.ts`, so a recent day is the mean of its hours' means and a
|
||||
* week the mean of its days, against a real database.
|
||||
*
|
||||
* Each pair of days: a workout hour of twelve readings at 150 and three
|
||||
* resting hours at 60 (day value 82.5), then a day with one reading at 60.
|
||||
* A bucket over both reads 71.25; the plain mean of the readings is 127.5.
|
||||
* Systolic pressure with the same readings keeps the plain mean.
|
||||
*/
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { buildGradedSeriesWithRollups } from "@/lib/insights/graded-series";
|
||||
import { readDayAggregates } from "@/lib/measurements/day-aggregates";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const USER = "graded-pulse-day";
|
||||
const DAY = 86_400_000;
|
||||
const now = new Date();
|
||||
const today = Date.UTC(
|
||||
now.getUTCFullYear(),
|
||||
now.getUTCMonth(),
|
||||
now.getUTCDate(),
|
||||
);
|
||||
/** A Monday about six weeks back: its Tuesday and Wednesday share a week. */
|
||||
const dow = (new Date(today).getUTCDay() + 6) % 7;
|
||||
const OLD_MONDAY = today - dow * DAY - 6 * 7 * DAY;
|
||||
const RECENT = today - 4 * DAY;
|
||||
|
||||
function pair(
|
||||
type: "PULSE" | "BLOOD_PRESSURE_SYS",
|
||||
start: number,
|
||||
tag: string,
|
||||
): Prisma.MeasurementCreateManyInput[] {
|
||||
const at: Array<[number, number]> = [];
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
at.push([start + 10 * 3_600_000 + i * 300_000, 150]);
|
||||
}
|
||||
for (const h of [12, 14, 16]) at.push([start + h * 3_600_000, 60]);
|
||||
at.push([start + DAY + 8 * 3_600_000, 60]);
|
||||
return at.map(([ms, value], i) => ({
|
||||
id: `${USER}-${type}-${tag}-${i}`,
|
||||
userId: USER,
|
||||
type,
|
||||
unit: type === "PULSE" ? "bpm" : "mmHg",
|
||||
source: "APPLE_HEALTH",
|
||||
value,
|
||||
measuredAt: new Date(ms),
|
||||
}));
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.user.create({ data: { id: USER, username: USER } });
|
||||
const data: Prisma.MeasurementCreateManyInput[] = [];
|
||||
for (const type of ["PULSE", "BLOOD_PRESSURE_SYS"] as const) {
|
||||
data.push(...pair(type, RECENT, "recent"));
|
||||
data.push(...pair(type, OLD_MONDAY + DAY, "old"));
|
||||
}
|
||||
await prisma.measurement.createMany({ data });
|
||||
}, 120_000);
|
||||
|
||||
describe("readDayAggregates for pulse", () => {
|
||||
it("carries day weights whose ratio is the day value", async () => {
|
||||
const rows = await readDayAggregates({
|
||||
userId: USER,
|
||||
type: "PULSE",
|
||||
since: new Date(RECENT),
|
||||
timeZone: "UTC",
|
||||
});
|
||||
const workout = rows.find(
|
||||
(r) => r.day === new Date(RECENT).toISOString().slice(0, 10),
|
||||
)!;
|
||||
expect(workout.n).toBe(15);
|
||||
expect(workout.min).toBe(60);
|
||||
expect(workout.max).toBe(150);
|
||||
expect(workout.weightSum).toBeCloseTo(1, 9);
|
||||
expect(workout.weightedSum! / workout.weightSum!).toBeCloseTo(82.5, 9);
|
||||
});
|
||||
|
||||
it("leaves every other type without them", async () => {
|
||||
const rows = await readDayAggregates({
|
||||
userId: USER,
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
since: new Date(RECENT),
|
||||
timeZone: "UTC",
|
||||
});
|
||||
expect(rows.every((r) => r.weightedSum === undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the pulse graded series", () => {
|
||||
it("gives a recent day its hours' mean and a week its days' mean", async () => {
|
||||
const g = await buildGradedSeriesWithRollups(USER, "PULSE", now, "UTC");
|
||||
const workout = g.recent.find(
|
||||
(d) => d.date === new Date(RECENT).toISOString().slice(0, 10),
|
||||
)!;
|
||||
expect(workout.mean).toBe(82.5);
|
||||
expect(workout.n).toBe(15);
|
||||
expect(g.weekly).toHaveLength(1);
|
||||
expect(g.weekly[0].mean).toBe(71.25);
|
||||
expect(g.weekly[0].n).toBe(16);
|
||||
});
|
||||
|
||||
it("keeps the plain mean for systolic pressure", async () => {
|
||||
const g = await buildGradedSeriesWithRollups(
|
||||
USER,
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
now,
|
||||
"UTC",
|
||||
);
|
||||
expect(g.weekly[0].mean).toBe(127.5);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user