mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user