diff --git a/src/lib/ai/coach/snapshot-blocks/__tests__/core-metrics-pulse-day.test.ts b/src/lib/ai/coach/snapshot-blocks/__tests__/core-metrics-pulse-day.test.ts new file mode 100644 index 000000000..d901a64b7 --- /dev/null +++ b/src/lib/ai/coach/snapshot-blocks/__tests__/core-metrics-pulse-day.test.ts @@ -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 = {}; + 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); + }); +}); diff --git a/src/lib/ai/coach/snapshot-blocks/core-metrics-block.ts b/src/lib/ai/coach/snapshot-blocks/core-metrics-block.ts index eda25227a..d5586ea8a 100644 --- a/src/lib/ai/coach/snapshot-blocks/core-metrics-block.ts +++ b/src/lib/ai/coach/snapshot-blocks/core-metrics-block.ts @@ -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 } : {}), }, diff --git a/src/lib/ai/coach/snapshot-series.ts b/src/lib/ai/coach/snapshot-series.ts index f06fc3328..a62f14f88 100644 --- a/src/lib/ai/coach/snapshot-series.ts +++ b/src/lib/ai/coach/snapshot-series.ts @@ -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( return grouped; } +function groupRowsByDay( + rows: Array<{ measuredAt: Date; value: number }>, + tz: string, +): Map> { + const out = new Map>(); + 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(); + const members = new Map>(); 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>(); 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), diff --git a/src/lib/doctor-report-pdf/__tests__/clinical-summary-pulse-trend.test.ts b/src/lib/doctor-report-pdf/__tests__/clinical-summary-pulse-trend.test.ts new file mode 100644 index 000000000..4ceda5fbc --- /dev/null +++ b/src/lib/doctor-report-pdf/__tests__/clinical-summary-pulse-trend.test.ts @@ -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("→"); + }); +}); diff --git a/src/lib/doctor-report-pdf/clinical-summary.ts b/src/lib/doctor-report-pdf/clinical-summary.ts index 013847f6a..037a765d5 100644 --- a/src/lib/doctor-report-pdf/clinical-summary.ts +++ b/src/lib/doctor-report-pdf/clinical-summary.ts @@ -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] ?? ""), diff --git a/src/lib/insights/__tests__/features.test.ts b/src/lib/insights/__tests__/features.test.ts index 94ad4694e..f43d56adf 100644 --- a/src/lib/insights/__tests__/features.test.ts +++ b/src/lib/insights/__tests__/features.test.ts @@ -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); + }); +}); diff --git a/src/lib/insights/__tests__/general-status.test.ts b/src/lib/insights/__tests__/general-status.test.ts index 5dded1ca4..1c3c74ada 100644 --- a/src/lib/insights/__tests__/general-status.test.ts +++ b/src/lib/insights/__tests__/general-status.test.ts @@ -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, + ]); + }); +}); diff --git a/src/lib/insights/__tests__/mood-status-pulse-day.test.ts b/src/lib/insights/__tests__/mood-status-pulse-day.test.ts new file mode 100644 index 000000000..3342c2728 --- /dev/null +++ b/src/lib/insights/__tests__/mood-status-pulse-day.test.ts @@ -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]); + }); +}); diff --git a/src/lib/insights/__tests__/pulse-hourly-mean-folds.test.ts b/src/lib/insights/__tests__/pulse-hourly-mean-folds.test.ts new file mode 100644 index 000000000..eadcbebaf --- /dev/null +++ b/src/lib/insights/__tests__/pulse-hourly-mean-folds.test.ts @@ -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); + }); +}); diff --git a/src/lib/insights/__tests__/pulse-status.test.ts b/src/lib/insights/__tests__/pulse-status.test.ts index 1f76a76a6..6965abddc 100644 --- a/src/lib/insights/__tests__/pulse-status.test.ts +++ b/src/lib/insights/__tests__/pulse-status.test.ts @@ -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); + }); +}); diff --git a/src/lib/insights/__tests__/signals-of-day-pulse.test.ts b/src/lib/insights/__tests__/signals-of-day-pulse.test.ts new file mode 100644 index 000000000..7aea4e5d1 --- /dev/null +++ b/src/lib/insights/__tests__/signals-of-day-pulse.test.ts @@ -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); + }); +}); diff --git a/src/lib/insights/bucket-series.ts b/src/lib/insights/bucket-series.ts index fa45092b9..74fe24c7f 100644 --- a/src/lib/insights/bucket-series.ts +++ b/src/lib/insights/bucket-series.ts @@ -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(); const monthly = new Map(); + // 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>, + 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>, + 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, })); diff --git a/src/lib/insights/feature-blocks.ts b/src/lib/insights/feature-blocks.ts index fcdf7ec74..9fdb1f270 100644 --- a/src/lib/insights/feature-blocks.ts +++ b/src/lib/insights/feature-blocks.ts @@ -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 diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index 93b287ffb..48f9497d3 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -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; } diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index 11f1fd5f1..de8420700 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -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 [ [ diff --git a/src/lib/insights/graded-series.ts b/src/lib/insights/graded-series.ts index 73e1938f8..ed50332bb 100644 --- a/src/lib/insights/graded-series.ts +++ b/src/lib/insights/graded-series.ts @@ -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, 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(); + const foldPoint = (map: Map, 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(); const weeklyAgg = new Map(); @@ -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)); diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index a827b1dc9..4c787f040 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -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 { + 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 diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index 084e17aed..b6143d2e1 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -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). diff --git a/src/lib/insights/signals-of-day.ts b/src/lib/insights/signals-of-day.ts index a6bf3f0b0..6f6c620e0 100644 --- a/src/lib/insights/signals-of-day.ts +++ b/src/lib/insights/signals-of-day.ts @@ -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. diff --git a/tests/integration/feature-blocks-pulse-day-mean.test.ts b/tests/integration/feature-blocks-pulse-day-mean.test.ts new file mode 100644 index 000000000..15bcffd6f --- /dev/null +++ b/tests/integration/feature-blocks-pulse-day-mean.test.ts @@ -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); + }); +}); diff --git a/tests/integration/graded-series-pulse-day-mean.test.ts b/tests/integration/graded-series-pulse-day-mean.test.ts new file mode 100644 index 000000000..5371dc30b --- /dev/null +++ b/tests/integration/graded-series-pulse-day-mean.test.ts @@ -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); + }); +});