Merge branch 'fix/chart-all-range-meaning' into release/v1.39.4

This commit is contained in:
Marc-André Bombeck committed 2026-09-27 14:22:57 +02:00
commit f98f8eaa24
16 files changed
+452 -35

No files matched your search

+3 -1
View File
@@ -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
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -2268,6 +2268,8 @@
"noData": "선택한 기간에 데이터가 없어요",
"bucketWeekly": "주 평균",
"bucketMonthly": "월 평균",
"bucketWeeklyDaily": "주별 일평균",
"bucketMonthlyDaily": "월별 일평균",
"systolic": "수축기",
"diastolic": "이완기",
"bodyWater": "체수분",
+2
View File
@@ -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",
+19 -10
View File
@@ -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({
) : (
<h2 className="text-sm font-semibold">{title}</h2>
)}
{activeBucket !== "day" && (
<span className="bg-muted/40 text-muted-foreground text-2xs hidden rounded-md px-1.5 py-0.5 font-medium tracking-wide uppercase sm:inline-flex">
{t(
activeBucket === "week"
? "charts.bucketWeekly"
: "charts.bucketMonthly",
)}
{bucketCaption && (
<span
data-slot="chart-bucket-caption"
className="bg-muted/40 text-muted-foreground text-2xs hidden rounded-md px-1.5 py-0.5 font-medium tracking-wide uppercase sm:inline-flex"
>
{t(bucketCaption)}
</span>
)}
{/* v1.4.16 phase B8 — comparison caption. Inline with the
@@ -2260,7 +2265,11 @@ export function HealthChart({
return (
<RichChartTooltip
active
label={dateLabel}
label={
bucketCaption
? `${dateLabel} · ${t(bucketCaption)}`
: dateLabel
}
rows={rows}
/>
);
@@ -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();
});
});
+29
View File
@@ -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";
}
+5 -1
View File
@@ -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(
@@ -122,9 +122,18 @@ function folded(
sumY += value;
}
const values = readings.map(([, v]) => v);
const byDay = new Map<string, number[]>();
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
+39 -3
View File
@@ -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(),
+16 -1
View File
@@ -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,
@@ -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<typeof history>,
type: MeasurementType,
period: { from: string; to: string },
): number {
const byDay = new Map<string, number[]>();
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,
);
}
}
},
);
});