diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 50bccf26d..c92ecdfe4 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -40749,7 +40749,9 @@ components: $ref: "#/components/schemas/MeasurementType" value: type: number - description: Daily mean (spot metrics) or SUM (cumulative). + description: "The day's mean (spot metrics) or total (cumulative). A row of a coarser tier (a window longer than the + daily cap) carries the average day of its bucket, the same quantity: a spot metric's mean of its + daily means, a cumulative metric's total over the days of the bucket that hold a reading." measuredAt: type: string format: date-time diff --git a/messages/de.json b/messages/de.json index a32f0bff0..096e51bd7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2268,6 +2268,8 @@ "noData": "Keine Daten im gewählten Zeitraum", "bucketWeekly": "Wochendurchschnitt", "bucketMonthly": "Monatsdurchschnitt", + "bucketWeeklyDaily": "Tagesschnitt je Woche", + "bucketMonthlyDaily": "Tagesschnitt je Monat", "days7Label": "7T", "days7Title": "Letzte 7 Tage", "days30Label": "30T", diff --git a/messages/en.json b/messages/en.json index 216062068..3261cdbe7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2268,6 +2268,8 @@ "noData": "No data in selected time range", "bucketWeekly": "Weekly avg", "bucketMonthly": "Monthly avg", + "bucketWeeklyDaily": "Daily avg per week", + "bucketMonthlyDaily": "Daily avg per month", "days7Label": "7d", "days7Title": "Last 7 days", "days30Label": "30d", diff --git a/messages/es.json b/messages/es.json index a15519de2..856524ed4 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2268,6 +2268,8 @@ "noData": "Sin datos en el periodo seleccionado", "bucketWeekly": "Media semanal", "bucketMonthly": "Media mensual", + "bucketWeeklyDaily": "Media diaria por semana", + "bucketMonthlyDaily": "Media diaria por mes", "days7Label": "7 d", "days7Title": "Últimos 7 días", "days30Label": "30 d", diff --git a/messages/fr.json b/messages/fr.json index ec8aaf4a0..d6209604a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2268,6 +2268,8 @@ "noData": "Aucune donnée sur la période sélectionnée", "bucketWeekly": "Moyenne hebdomadaire", "bucketMonthly": "Moyenne mensuelle", + "bucketWeeklyDaily": "Moyenne par jour, par semaine", + "bucketMonthlyDaily": "Moyenne par jour, par mois", "days7Label": "7 j", "days7Title": "7 derniers jours", "days30Label": "30 j", diff --git a/messages/it.json b/messages/it.json index 7b5a9dc8e..151e22053 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2268,6 +2268,8 @@ "noData": "Nessun dato nel periodo selezionato", "bucketWeekly": "Media settimanale", "bucketMonthly": "Media mensile", + "bucketWeeklyDaily": "Media giornaliera per settimana", + "bucketMonthlyDaily": "Media giornaliera per mese", "days7Label": "7 g", "days7Title": "Ultimi 7 giorni", "days30Label": "30 g", diff --git a/messages/ko.json b/messages/ko.json index eca3ce056..dd81bcdb4 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -2268,6 +2268,8 @@ "noData": "선택한 기간에 데이터가 없어요", "bucketWeekly": "주 평균", "bucketMonthly": "월 평균", + "bucketWeeklyDaily": "주별 일평균", + "bucketMonthlyDaily": "월별 일평균", "systolic": "수축기", "diastolic": "이완기", "bodyWater": "체수분", diff --git a/messages/pl.json b/messages/pl.json index 131f96e46..33cec9808 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2268,6 +2268,8 @@ "noData": "Brak danych w wybranym okresie", "bucketWeekly": "Średnia tygodniowa", "bucketMonthly": "Średnia miesięczna", + "bucketWeeklyDaily": "Średnia dzienna w tygodniu", + "bucketMonthlyDaily": "Średnia dzienna w miesiącu", "days7Label": "7 dni", "days7Title": "Ostatnie 7 dni", "days30Label": "30 dni", diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index d2d766ce8..229f38a21 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -59,6 +59,7 @@ import type { ComparisonBaseline, } from "@/lib/dashboard-layout"; import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; +import { bucketCaptionKey } from "@/lib/charts/bucket-caption"; import type { MeasurementType } from "@/generated/prisma/client"; import { ChartOverlayControls } from "./chart-overlay-controls"; import { ChartDataTable, type ChartDataTableColumn } from "./chart-data-table"; @@ -1018,8 +1019,9 @@ export function HealthChart({ // v1.4.29.1 — cumulative HealthKit types (steps, active energy, // distance, flights, daylight) must reduce with sum, not the // per-sample average. The server already returns one row per - // day; a day that arrives as several rows (the coarser tiers - // of the "All" range) must still add up rather than average. + // day (or, for the coarser tiers of the "All" range, one row per + // week or month carrying its average day); a day that arrives + // as several rows must still add up rather than average. const isCumulative = CUMULATIVE_HK_TYPES.has( type as MeasurementType, ); @@ -1339,6 +1341,10 @@ export function HealthChart({ // the decision separately" drift the visibleSlice doc-comment warns // about, and the two had already diverged on the windowing semantics. const activeBucket: ChartBucketType = visibleSlice?.bucketType ?? "day"; + // What a folded point is: the average day of its week or month (a daily + // total's average for steps and the like). Named on the caption chip and + // in the tooltip. + const bucketCaption = bucketCaptionKey(activeBucket, types); const yDomain = useMemo<[number, number] | undefined>(() => { if (!chartDataWithCompare?.length) return undefined; @@ -1730,13 +1736,12 @@ export function HealthChart({ ) : (

{title}

)} - {activeBucket !== "day" && ( - - {t( - activeBucket === "week" - ? "charts.bucketWeekly" - : "charts.bucketMonthly", - )} + {bucketCaption && ( + + {t(bucketCaption)} )} {/* v1.4.16 phase B8 — comparison caption. Inline with the @@ -2260,7 +2265,11 @@ export function HealthChart({ return ( ); diff --git a/src/lib/charts/__tests__/bucket-caption.test.ts b/src/lib/charts/__tests__/bucket-caption.test.ts new file mode 100644 index 000000000..70e7fd8ae --- /dev/null +++ b/src/lib/charts/__tests__/bucket-caption.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { bucketCaptionKey } from "../bucket-caption"; + +describe("bucketCaptionKey", () => { + it("names a cumulative metric's folded point as a daily average", () => { + expect(bucketCaptionKey("week", ["ACTIVITY_STEPS"])).toBe( + "charts.bucketWeeklyDaily", + ); + expect(bucketCaptionKey("month", ["ACTIVE_ENERGY_BURNED"])).toBe( + "charts.bucketMonthlyDaily", + ); + }); + + it("keeps the plain average for a level", () => { + expect(bucketCaptionKey("week", ["WEIGHT"])).toBe("charts.bucketWeekly"); + expect( + bucketCaptionKey("month", ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]), + ).toBe("charts.bucketMonthly"); + }); + + it("says nothing for days", () => { + expect(bucketCaptionKey("day", ["ACTIVITY_STEPS"])).toBeNull(); + }); +}); diff --git a/src/lib/charts/bucket-caption.ts b/src/lib/charts/bucket-caption.ts new file mode 100644 index 000000000..2af076a8a --- /dev/null +++ b/src/lib/charts/bucket-caption.ts @@ -0,0 +1,29 @@ +/** + * What a chart point means once the visible range folds days into weeks or + * months, as the caption and the tooltip name it. + * + * Every point of a week or month is the average day of that bucket, on every + * read path (the day points the chart folds itself, and the rollup tier rows + * the server serves for the "All" range). For a level (weight, pulse) that is + * simply the week's or month's average. For a cumulative metric (steps, + * energy, distance) it is the average DAILY total, which keeps the axis + * comparable with the 7, 30 and 90 day tabs; calling it a "weekly average" + * read as an average week's total, a figure seven times larger. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; +import type { ChartBucketType } from "@/lib/charts/bucket-time-series"; + +export function bucketCaptionKey( + bucket: ChartBucketType, + types: readonly string[], +): string | null { + if (bucket === "day") return null; + const daily = + types.length > 0 && + types.every((type) => CUMULATIVE_HK_TYPES.has(type as MeasurementType)); + if (bucket === "week") { + return daily ? "charts.bucketWeeklyDaily" : "charts.bucketWeekly"; + } + return daily ? "charts.bucketMonthlyDaily" : "charts.bucketMonthly"; +} diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 103bea081..13158a3e4 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -760,7 +760,11 @@ const seriesResponse = z // `GET /api/measurements?aggregate=daily&source=rollup` emits. const seriesBatchRowSchema = z.object({ type: measurementTypeEnum, - value: z.number().describe("Daily mean (spot metrics) or SUM (cumulative)."), + value: z + .number() + .describe( + "The day's mean (spot metrics) or total (cumulative). A row of a coarser tier (a window longer than the daily cap) carries the average day of its bucket, the same quantity: a spot metric's mean of its daily means, a cumulative metric's total over the days of the bucket that hold a reading.", + ), measuredAt: z.iso .datetime({ offset: true }) .describe( diff --git a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts index d436b6408..8ec37ec0a 100644 --- a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts +++ b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts @@ -122,9 +122,18 @@ function folded( sumY += value; } const values = readings.map(([, v]) => v); + const byDay = new Map(); + for (const [day, value] of readings) { + byDay.set(day, [...(byDay.get(day) ?? []), value]); + } + const dayMeans = [...byDay.values()].map( + (v) => v.reduce((s, x) => s + x, 0) / v.length, + ); return { bucket_start: new Date(bucketStart), count: readings.length, + days: byDay.size, + sum_day_mean: dayMeans.reduce((s, x) => s + x, 0), sum_y: sumY, min_value: Math.min(...values), max_value: Math.max(...values), @@ -475,27 +484,115 @@ describe("readTieredRollupSeries", () => { }); }); - it("serves the summed total for step-like metrics and drops the spread", async () => { - aggregate.mockResolvedValueOnce( - spanOf("2024-01-01T00:00:00.000Z", "2026-01-01T00:00:00.000Z"), - ); - queryRaw.mockResolvedValueOnce([ - folded( - "2024-01-01T00:00:00.000Z", - Array.from({ length: 30 }, (_, i): [string, number] => [ - `2024-01-${String(i + 1).padStart(2, "0")}`, - 8000, - ]), - ), + // The chart folds its shorter ranges (and the live fallback) from day + // points into the average day of each week or month. A tier row must be the + // same quantity, whichever tier the history's length selects. + describe("the average day at every tier", () => { + // January 2026: steps on 20 of its days, from 4 000 to 13 500. + const JAN = Array.from({ length: 20 }, (_, i): [string, number] => [ + `2026-01-${String(i + 1).padStart(2, "0")}`, + 4_000 + i * 500, ]); - const result = await readTieredRollupSeries({ - userId: "u", - type: "ACTIVITY_STEPS", - ...win(3650), + const averageDay = JAN.reduce((s, [, v]) => s + v, 0) / JAN.length; + + /** How the chart folds a tier's rows into the month on screen. */ + function chartMonth(rows: Array<{ measuredAt: string; value: number }>) { + return bucketTimeSeries( + rows.map((row) => ({ + timestamp: new Date(row.measuredAt), + values: { v: row.value }, + })), + { bucket: "month", timeZone: "Europe/Berlin" }, + ).points.map((p) => p.values.v); + } + + it("serves a MONTH row of a long history as its average day", async () => { + aggregate.mockResolvedValueOnce( + spanOf("2023-01-01T00:00:00.000Z", "2026-01-20T00:00:00.000Z"), + ); + queryRaw.mockResolvedValueOnce([folded("2026-01-01T00:00:00.000Z", JAN)]); + const result = await readTieredRollupSeries({ + userId: "u", + type: "ACTIVITY_STEPS", + ...win(3650), + }); + expect(result?.granularity).toBe("MONTH"); + expect(result?.rows[0].value).toBe(averageDay); + expect(result?.rows[0].count).toBe(JAN.length); + expect(result?.rows[0].minValue).toBeUndefined(); + expect(result?.rows[0].maxValue).toBeUndefined(); + }); + + it("gives the same month for a short history served by days", async () => { + aggregate.mockResolvedValueOnce( + spanOf("2026-01-01T00:00:00.000Z", "2026-01-20T00:00:00.000Z"), + ); + findMany.mockResolvedValueOnce( + JAN.map(([day, value]) => dayRow(day, "APPLE_HEALTH", 1, value)), + ); + const result = await readTieredRollupSeries({ + userId: "u", + type: "ACTIVITY_STEPS", + ...win(3650), + }); + expect(result?.granularity).toBe("DAY"); + expect(chartMonth(result!.rows)).toEqual([averageDay]); + }); + + it("gives each WEEK row of a middle-length history its own average day", async () => { + aggregate.mockResolvedValueOnce( + spanOf("2025-01-01T00:00:00.000Z", "2026-01-20T00:00:00.000Z"), + ); + const week = JAN.slice(4, 11); // Monday 5 to Sunday 11 January + queryRaw.mockResolvedValueOnce([ + folded("2026-01-05T00:00:00.000Z", week), + ]); + const result = await readTieredRollupSeries({ + userId: "u", + type: "ACTIVITY_STEPS", + ...win(3650), + }); + expect(result?.granularity).toBe("WEEK"); + expect(result?.rows[0].value).toBe( + week.reduce((s, [, v]) => s + v, 0) / week.length, + ); + }); + + it("weighs each day once for a level, as the chart's own fold does", async () => { + // Two readings on the 3rd (120, 130), one on the 4th (100): the + // average day is 112.5, while every reading weighing one gives 116.7. + const readings: Array<[string, number]> = [ + ["2026-01-03", 120], + ["2026-01-03", 130], + ["2026-01-04", 100], + ]; + aggregate.mockResolvedValueOnce( + spanOf("2023-01-01T00:00:00.000Z", "2026-01-04T00:00:00.000Z"), + ); + queryRaw.mockResolvedValueOnce([ + folded("2026-01-01T00:00:00.000Z", readings), + ]); + const monthly = await readTieredRollupSeries({ + userId: "u", + type: "BLOOD_PRESSURE_SYS", + ...win(3650), + }); + expect(monthly?.rows[0].value).toBe(112.5); + + aggregate.mockResolvedValueOnce( + spanOf("2026-01-03T00:00:00.000Z", "2026-01-04T00:00:00.000Z"), + ); + findMany.mockResolvedValueOnce([ + dayRow("2026-01-03", "MANUAL", 2, 125), + dayRow("2026-01-04", "MANUAL", 1, 100), + ]); + const daily = await readTieredRollupSeries({ + userId: "u", + type: "BLOOD_PRESSURE_SYS", + ...win(3650), + }); + expect(chartMonth(daily!.rows)).toEqual([112.5]); }); - expect(result?.rows[0].value).toBe(240_000); - expect(result?.rows[0].minValue).toBeUndefined(); - expect(result?.rows[0].maxValue).toBeUndefined(); }); // v1.37.29 — the tier keys off the ACTUAL data span, not the requested diff --git a/src/lib/rollups/measurement-read-wmy.ts b/src/lib/rollups/measurement-read-wmy.ts index 8d353ad06..a67d91c3e 100644 --- a/src/lib/rollups/measurement-read-wmy.ts +++ b/src/lib/rollups/measurement-read-wmy.ts @@ -296,6 +296,37 @@ export function pickRollupGranularityForWindow( */ const TIER_ROW_ANCHOR_MS = 12 * 3_600_000; +/** + * A cumulative type's tier row as the average day of its bucket: the bucket's + * total over the days that hold a reading. A day row is its own total, which + * is the same quantity. The chart folds its shorter ranges from day totals + * into the mean day of each week or month, and the live fallback serves day + * totals too; a WEEK or MONTH row carrying its whole total put a monthly sum + * where every other path puts a daily figure, so the "All" range read ~30 + * times higher once a history was long enough to be served monthly, under a + * caption that said average. + */ +function dailyAverage(r: { + sumValue: number | null; + mean: number; + count: number; + days: number; +}): number { + const total = r.sumValue ?? r.mean * r.count; + return r.days > 1 ? total / r.days : total; +} + +/** + * A level type's tier row: the mean of its days' means. The count-weighted + * `mean` let a day with many readings outweigh the rest, while the chart's + * shorter ranges and the live fallback weigh each day once. + */ +function chartLevel(r: { mean: number; dayMean?: number }): number { + return typeof r.dayMean === "number" && Number.isFinite(r.dayMean) + ? r.dayMean + : r.mean; +} + /** DAY → WEEK → MONTH → YEAR, finest first. */ const TIER_ORDER: RollupGranularity[] = ["DAY", "WEEK", "MONTH", "YEAR"]; @@ -317,8 +348,10 @@ const TIER_ORDER: RollupGranularity[] = ["DAY", "WEEK", "MONTH", "YEAR"]; * the compositional contract above), so a 5-year window rendered as ~60 * MONTH points carries the same trend as the ~1 800 DAY points would have * — minus the truncation. `minValue` / `maxValue` ride through for the - * range band on spot metrics; cumulative metrics (steps, energy, - * distance) surface the bucket's summed total and drop the spread. + * range band on spot metrics. The value is the average day of the bucket + * at every tier, as the chart's own fold of day points gives it: a level's + * days each weigh one, and a cumulative metric (steps, energy, distance) + * carries its total over the days that hold a reading, without a spread. * * Coverage handling: the tier is chosen from the span of the window's DAY * buckets and read folded from them. Returns `null` when the window holds no @@ -409,7 +442,10 @@ export async function readTieredRollupSeries(opts: { granularity, rows: rows.map((r) => ({ type, - value: useSum ? (r.sumValue ?? r.mean * r.count) : r.mean, + // The chart's quantity at every tier: the average day of the bucket, + // each day weighing one, as its shorter ranges and the live fallback + // fold it. + value: useSum ? dailyAverage(r) : chartLevel(r), measuredAt: new Date( r.bucketStart.getTime() + TIER_ROW_ANCHOR_MS, ).toISOString(), diff --git a/src/lib/rollups/measurement-read.ts b/src/lib/rollups/measurement-read.ts index c598645ab..c183dc110 100644 --- a/src/lib/rollups/measurement-read.ts +++ b/src/lib/rollups/measurement-read.ts @@ -462,6 +462,13 @@ export function utcBucketEnd(at: Date, granularity: RollupGranularity): Date { export interface CanonicalRollupBucket { bucketStart: Date; count: number; + /** Days of the bucket that hold a reading (1 for a DAY bucket). */ + days: number; + /** + * The mean of the bucket's daily means, each day weighing one: what the + * chart's own fold of day points gives. `mean` weighs every reading. + */ + dayMean: number; mean: number; minValue: number; maxValue: number; @@ -483,6 +490,10 @@ export interface CanonicalRollupBucket { interface FoldedBucketRow { bucket_start: Date; count: number; + /** Canonical DAY rows folded into the bucket: its days with a reading. */ + days: number; + /** The days' own means added up (each day weighs one). */ + sum_day_mean: number; sum_y: number; min_value: number; max_value: number; @@ -528,6 +539,8 @@ function composeFoldedBucket(row: FoldedBucketRow): CanonicalRollupBucket { return { bucketStart: new Date(row.bucket_start), count, + days: Number(row.days), + dayMean: Number(row.sum_day_mean) / Number(row.days), mean, minValue: Number(row.min_value), maxValue: Number(row.max_value), @@ -616,7 +629,7 @@ export async function readCanonicalRollupBuckets(opts: { ? opts.userPriorityJson : await loadUserSourcePriority(userId); return collapseRollupRowsBySource(dayRows, type, priority).map( - ({ source: _source, ...day }) => day, + ({ source: _source, ...day }) => ({ ...day, days: 1, dayMean: day.mean }), ); } @@ -653,6 +666,8 @@ export async function readCanonicalRollupBuckets(opts: { date_trunc(${unit}, c."bucket_start" AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AS bucket_start, SUM(c."count")::int AS count, + COUNT(*)::int AS days, + SUM(c."mean")::double precision AS sum_day_mean, SUM(COALESCE(c."sum_value", c."count" * c."mean"))::double precision AS sum_y, MIN(c."min_value")::double precision AS min_value, diff --git a/tests/integration/chart-all-range-meaning.test.ts b/tests/integration/chart-all-range-meaning.test.ts new file mode 100644 index 000000000..bfc406909 --- /dev/null +++ b/tests/integration/chart-all-range-meaning.test.ts @@ -0,0 +1,186 @@ +/** + * v1.39.4 — the chart's "All" range means one thing at every history length. + * + * A long history is served from the WEEK or MONTH rollup tier, a short one + * from DAY rows, and a coverage miss from the live table. The chart folds + * whatever arrives into the average day of each week (a range up to two + * years) or month (longer). Seeded with real rows and folded through the real + * rollup writer, a fully covered week or month must read the same average day + * on every path: for steps the average daily total, for a level the average + * of its days. Before, a MONTH-tier month of steps read + * as the month's total, about thirty times the same month served by days. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { readDailySeries } from "@/lib/measurements/daily-series-read"; +import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups"; +import { bucketTimeSeries } from "@/lib/charts/bucket-time-series"; +import type { MeasurementType } from "@/generated/prisma/client"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/jobs/boss-instance", () => ({ + getGlobalBoss: vi.fn(() => null), +})); + +const TZ = "America/Los_Angeles"; +const DAY_MS = 86_400_000; +const NOW = new Date("2026-09-20T17:00:00.000Z"); +/** The week and the month every history below covers fully. */ +const WEEK = { from: "2026-06-08", to: "2026-06-14" }; +const MONTH = { from: "2026-06-01", to: "2026-06-30" }; + +let seq = 0; +async function seedUser() { + seq += 1; + return getPrismaClient().user.create({ + data: { + username: `chart-all-${seq}`, + email: `chart-all-${seq}@example.test`, + role: "USER", + timezone: TZ, + }, + }); +} + +/** + * `days` days ending yesterday, at 18:00 UTC (11:00 in Los Angeles, the same + * calendar day in both). Steps come as two rows a day; the level as one or + * two readings, so a reading-weighted mean would differ from a day-weighted + * one. + */ +function history(days: number) { + const rows: Array<{ type: MeasurementType; value: number; at: Date }> = []; + const end = Date.parse("2026-09-19T18:00:00.000Z"); + for (let i = 0; i < days; i += 1) { + const at = new Date(end - i * DAY_MS); + const steps = 6_000 + ((i * 37) % 5_000); + rows.push({ type: "ACTIVITY_STEPS", value: steps - 1_000, at }); + rows.push({ + type: "ACTIVITY_STEPS", + value: 1_000, + at: new Date(at.getTime() + 60_000), + }); + rows.push({ type: "PULSE", value: 60 + (i % 9), at }); + if (i % 3 === 0) { + rows.push({ + type: "PULSE", + value: 90, + at: new Date(at.getTime() + 120_000), + }); + } + } + return rows; +} + +/** The average day of a period straight from the seeded rows. */ +function expectedAverageDay( + rows: ReturnType, + type: MeasurementType, + period: { from: string; to: string }, +): number { + const byDay = new Map(); + for (const r of rows) { + if (r.type !== type) continue; + const day = r.at.toISOString().slice(0, 10); + if (day < period.from || day > period.to) continue; + byDay.set(day, [...(byDay.get(day) ?? []), r.value]); + } + const perDay = [...byDay.values()].map((values) => + type === "ACTIVITY_STEPS" + ? values.reduce((s, v) => s + v, 0) + : values.reduce((s, v) => s + v, 0) / values.length, + ); + return perDay.reduce((s, v) => s + v, 0) / perDay.length; +} + +/** The chart's point for `period`, folded at the bucket the range shows. */ +async function chartPoint( + userId: string, + type: MeasurementType, + bucket: "week" | "month", + period: { from: string }, +) { + const rows = await readDailySeries({ + userId, + type, + from: new Date(NOW.getTime() - 3_650 * DAY_MS), + to: NOW, + priorityJson: null, + timeZone: TZ, + }); + const points = bucketTimeSeries( + rows.map((row) => ({ + timestamp: new Date(row.measuredAt), + values: { v: row.value }, + })), + { bucket, timeZone: TZ }, + ).points; + const point = points.find( + (p) => new Date(p.timestamp).toISOString().slice(0, 10) === period.from, + ); + return point?.values.v; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("chart All range — one quantity at every history length", () => { + it.each([ + ["days (a short history)", 200], + ["weeks (one to two years)", 500], + ["months (a long history)", 900], + ])( + "reads the same average day for a period served by %s, and by the live table", + async (_label, days) => { + const user = await seedUser(); + const rows = history(days); + await getPrismaClient().measurement.createMany({ + data: rows.map((r) => ({ + userId: user.id, + type: r.type, + value: r.value, + unit: r.type === "PULSE" ? "bpm" : "count", + source: "APPLE_HEALTH" as const, + measuredAt: r.at, + })), + }); + await recomputeUserRollups(user.id, { + from: new Date(NOW.getTime() - (days + 40) * DAY_MS), + to: NOW, + }); + + // The chart folds a span over two years into months, else weeks. + const bucket = days > 730 ? "month" : "week"; + const period = bucket === "month" ? MONTH : WEEK; + for (const type of ["ACTIVITY_STEPS", "PULSE"] as const) { + const expected = expectedAverageDay(rows, type, period); + const tier = await chartPoint(user.id, type, bucket, period); + expect(tier, `${type} from the tier`).toBeCloseTo(expected, 6); + } + + // A coverage miss serves the live table's days instead. Its daily + // path keeps the oldest 365 days, so only the short history still + // reaches June through it. + if (days <= 365) { + await getPrismaClient().measurementRollup.deleteMany({ + where: { userId: user.id }, + }); + for (const type of ["ACTIVITY_STEPS", "PULSE"] as const) { + const live = await chartPoint(user.id, type, bucket, period); + expect(live, `${type} from the live table`).toBeCloseTo( + expectedAverageDay(rows, type, period), + 6, + ); + } + } + }, + ); +});