mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Merge branch 'fix/chart-all-range-meaning' into release/v1.39.4
This commit is contained in:
commit
f98f8eaa24
16 files changed
+452
-35
No files matched your search
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2268,6 +2268,8 @@
|
||||
"noData": "선택한 기간에 데이터가 없어요",
|
||||
"bucketWeekly": "주 평균",
|
||||
"bucketMonthly": "월 평균",
|
||||
"bucketWeeklyDaily": "주별 일평균",
|
||||
"bucketMonthlyDaily": "월별 일평균",
|
||||
"systolic": "수축기",
|
||||
"diastolic": "이완기",
|
||||
"bodyWater": "체수분",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user