mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Refine the tiered rollup read by the actual data span
The tier for a chart series keyed off the requested window width. The All range always requests ~3650 days, so every account got MONTH buckets regardless of how much history exists — a record spanning four months came back as four monthly means, and the chart captioned them from the real span as a weekly average. The reader now re-reads at the tier the returned rows' span calls for; a coverage miss on the finer tier keeps the coarser rows. The ladder comment claimed to mirror the client's pickBucket thresholds and misquoted them. The two ladders are deliberately not identical; a test now pins the direction that keeps the caption honest (server never coarser than the client captions) for every span up to ten years.
This commit is contained in:
1 parent
3ce5411d19
commit
0823a4e222
2 files changed
+177
-12
No files matched your search
@@ -43,10 +43,12 @@ vi.mock("@/lib/db", () => ({
|
||||
|
||||
import {
|
||||
aggregateWmyBuckets,
|
||||
pickRollupGranularityForWindow,
|
||||
readBestGranularityRollups,
|
||||
readTieredRollupSeries,
|
||||
type RollupBucketRow,
|
||||
} from "../measurement-read-wmy";
|
||||
import { pickBucket } from "@/lib/charts/bucket-time-series";
|
||||
|
||||
const { findMany } = mocks;
|
||||
|
||||
@@ -70,6 +72,12 @@ function bucket(
|
||||
|
||||
beforeEach(() => {
|
||||
findMany.mockReset();
|
||||
// Default to "no coverage" for any read a test did not queue explicitly —
|
||||
// the v1.37.29 span-refinement probe in `readTieredRollupSeries` issues an
|
||||
// extra read when a fixture's rows span less than the requested window,
|
||||
// and an unqueued vi.fn() would resolve `undefined` and crash the reader
|
||||
// instead of modelling an unminted tier.
|
||||
findMany.mockResolvedValue([]);
|
||||
mocks.userFindUnique.mockReset();
|
||||
mocks.userFindUnique.mockResolvedValue({ sourcePriorityJson: null });
|
||||
});
|
||||
@@ -454,4 +462,120 @@ describe("readTieredRollupSeries", () => {
|
||||
expect(result?.rows[0].minValue).toBe(78);
|
||||
expect(result?.rows[0].maxValue).toBe(85);
|
||||
});
|
||||
|
||||
// v1.37.29 — the tier keys off the ACTUAL data span, not the requested
|
||||
// window width. The "All" tab always requests ~3650 days, so pre-fix a
|
||||
// record whose history spans four months came back as four MONTH means
|
||||
// while the chart captioned them from the real span — "weekly average"
|
||||
// over monthly buckets.
|
||||
describe("span refinement", () => {
|
||||
it("re-reads at the tier the real span calls for (4 months of data on an All request → DAY)", async () => {
|
||||
const months = [
|
||||
bucket("2026-02-01T00:00:00.000Z"),
|
||||
bucket("2026-03-01T00:00:00.000Z"),
|
||||
bucket("2026-04-01T00:00:00.000Z"),
|
||||
bucket("2026-05-01T00:00:00.000Z"),
|
||||
];
|
||||
const days = [
|
||||
bucket("2026-02-03T00:00:00.000Z"),
|
||||
bucket("2026-03-14T00:00:00.000Z"),
|
||||
bucket("2026-04-09T00:00:00.000Z"),
|
||||
bucket("2026-05-28T00:00:00.000Z"),
|
||||
];
|
||||
findMany.mockResolvedValueOnce(months).mockResolvedValueOnce(days);
|
||||
|
||||
const result = await readTieredRollupSeries({
|
||||
userId: "u",
|
||||
type: "WEIGHT",
|
||||
...win(3650),
|
||||
});
|
||||
|
||||
// Span of the MONTH rows ≈ 89 days → the display ladder wants DAY,
|
||||
// and the refinement jumps straight there (no WEEK stop-over).
|
||||
expect(findMany.mock.calls[0][0].where.granularity).toBe("MONTH");
|
||||
expect(findMany.mock.calls[1][0].where.granularity).toBe("DAY");
|
||||
expect(findMany).toHaveBeenCalledTimes(2);
|
||||
expect(result?.granularity).toBe("DAY");
|
||||
expect(result?.rows).toHaveLength(4);
|
||||
expect(result?.rows[0].measuredAt).toBe("2026-02-03T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("refines to WEEK for a span between one and two years", async () => {
|
||||
const months = [
|
||||
bucket("2025-01-01T00:00:00.000Z"),
|
||||
bucket("2026-05-01T00:00:00.000Z"),
|
||||
];
|
||||
const weeks = [
|
||||
bucket("2024-12-30T00:00:00.000Z"),
|
||||
bucket("2026-04-27T00:00:00.000Z"),
|
||||
];
|
||||
findMany.mockResolvedValueOnce(months).mockResolvedValueOnce(weeks);
|
||||
|
||||
const result = await readTieredRollupSeries({
|
||||
userId: "u",
|
||||
type: "WEIGHT",
|
||||
...win(3650),
|
||||
});
|
||||
|
||||
expect(findMany.mock.calls[1][0].where.granularity).toBe("WEEK");
|
||||
expect(result?.granularity).toBe("WEEK");
|
||||
});
|
||||
|
||||
it("keeps the coarser rows when the finer tier has no coverage", async () => {
|
||||
const months = [
|
||||
bucket("2026-01-01T00:00:00.000Z"),
|
||||
bucket("2026-05-01T00:00:00.000Z"),
|
||||
];
|
||||
findMany.mockResolvedValueOnce(months).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await readTieredRollupSeries({
|
||||
userId: "u",
|
||||
type: "WEIGHT",
|
||||
...win(3650),
|
||||
});
|
||||
|
||||
expect(result?.granularity).toBe("MONTH");
|
||||
expect(result?.rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not probe when the rows already span the requested tier", async () => {
|
||||
const months = [
|
||||
bucket("2018-01-01T00:00:00.000Z"),
|
||||
bucket("2026-05-01T00:00:00.000Z"),
|
||||
];
|
||||
findMany.mockResolvedValueOnce(months);
|
||||
|
||||
const result = await readTieredRollupSeries({
|
||||
userId: "u",
|
||||
type: "WEIGHT",
|
||||
...win(3650),
|
||||
});
|
||||
|
||||
expect(findMany).toHaveBeenCalledTimes(1);
|
||||
expect(result?.granularity).toBe("MONTH");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// v1.37.29 — the server tier ladder and the client caption ladder
|
||||
// (`pickBucket`) are DIFFERENT functions with different thresholds, and
|
||||
// their divergence is exactly what shipped monthly means under a
|
||||
// "weekly average" chip. The invariant that keeps the caption honest is
|
||||
// directional: for any data span, the tier the server serves is never
|
||||
// COARSER than the bucket the client captions — the client may fold
|
||||
// finer rows up to its caption, but it cannot split coarser ones.
|
||||
describe("server tier vs client caption ladder", () => {
|
||||
const SERVER_COARSENESS = { DAY: 0, WEEK: 1, MONTH: 2, YEAR: 3 } as const;
|
||||
const CLIENT_COARSENESS = { day: 0, week: 1, month: 2 } as const;
|
||||
|
||||
it("never serves coarser than the client captions, for every span up to ten years", () => {
|
||||
for (let span = 1; span <= 3650; span++) {
|
||||
const server = pickRollupGranularityForWindow(span);
|
||||
const client = pickBucket(span);
|
||||
expect(
|
||||
SERVER_COARSENESS[server],
|
||||
`span ${span}d: server ${server} vs client caption ${client}`,
|
||||
).toBeLessThanOrEqual(CLIENT_COARSENESS[client]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -303,20 +303,28 @@ export interface TieredSeriesRow {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the rollup granularity that matches the chart's own client-side
|
||||
* `bucketTimeSeries` downsampler (`src/lib/charts/bucket-time-series.ts`):
|
||||
* Pick the rollup granularity for a chart window of `windowDays`:
|
||||
*
|
||||
* - > 730 days → MONTH (the chart renders month points)
|
||||
* - 366–730 → WEEK (the chart renders week points)
|
||||
* - > 730 days → MONTH
|
||||
* - 366–730 → WEEK
|
||||
* - ≤ 365 → DAY (daily resolution; the DAY cap covers it)
|
||||
*
|
||||
* Mirroring the display tier means the server returns exactly the
|
||||
* resolution the chart will paint — no finer (which would truncate at the
|
||||
* DAY cap) and no coarser (which would drop visible detail). The finer
|
||||
* fallback in `readTieredRollupSeries` still rescues a tenant whose
|
||||
* coarse buckets the worker has not minted yet.
|
||||
* This ladder must stay COMPATIBLE with the chart's client-side
|
||||
* `pickBucket` (`src/lib/charts/bucket-time-series.ts`), which captions
|
||||
* the rendered series from the visible data span (day ≤ 90, week 91–730,
|
||||
* month > 730): for any span the tier chosen here is never COARSER than
|
||||
* the caption the client will print, so a "Wochendurchschnitt" chip
|
||||
* always sits over points the client actually folded into weeks. The
|
||||
* ladders are not identical — this one may be finer (DAY rows for a
|
||||
* 91–365 d span; the client folds them into the weeks it captions) —
|
||||
* and a test pins the never-coarser direction across every span.
|
||||
*
|
||||
* The finer fallback in `readTieredRollupSeries` still rescues a tenant
|
||||
* whose coarse buckets the worker has not minted yet.
|
||||
*/
|
||||
function pickRollupGranularityForWindow(windowDays: number): RollupGranularity {
|
||||
export function pickRollupGranularityForWindow(
|
||||
windowDays: number,
|
||||
): RollupGranularity {
|
||||
if (windowDays > 730) return "MONTH";
|
||||
if (windowDays > 365) return "WEEK";
|
||||
return "DAY";
|
||||
@@ -393,8 +401,8 @@ export async function readTieredRollupSeries(opts: {
|
||||
// drop detail the chart can render). Stop at the first tier with coverage.
|
||||
const targetIdx = TIER_ORDER.indexOf(target);
|
||||
for (let i = targetIdx; i >= 0; i--) {
|
||||
const granularity = TIER_ORDER[i];
|
||||
const rows = await readGranularity(
|
||||
let granularity = TIER_ORDER[i];
|
||||
let rows = await readGranularity(
|
||||
userId,
|
||||
type,
|
||||
granularity,
|
||||
@@ -403,6 +411,39 @@ export async function readTieredRollupSeries(opts: {
|
||||
priority,
|
||||
);
|
||||
if (rows && rows.length > 0) {
|
||||
// Refine the tier by the ACTUAL data span, not the requested window.
|
||||
// The "Alle" tab always requests ~3650 days, so keying the tier off
|
||||
// the request width alone handed EVERY account MONTH buckets — a
|
||||
// record whose history spans four months came back as four monthly
|
||||
// means while the client (which captions from the real span) labelled
|
||||
// them "weekly average". Re-reading at the tier the real span calls
|
||||
// for returns the resolution the chart will actually caption. A
|
||||
// coverage miss on the finer tier keeps the coarser rows — finer
|
||||
// tiers are the base tiers the workers mint first, so in practice
|
||||
// this probe only fires when the refinement is genuinely available.
|
||||
// The walk is strictly finer-only, so it terminates.
|
||||
for (;;) {
|
||||
const spanDays = Math.ceil(
|
||||
(rows[rows.length - 1].bucketStart.getTime() -
|
||||
rows[0].bucketStart.getTime()) /
|
||||
86_400_000,
|
||||
);
|
||||
const refined = pickRollupGranularityForWindow(Math.max(1, spanDays));
|
||||
if (TIER_ORDER.indexOf(refined) >= TIER_ORDER.indexOf(granularity)) {
|
||||
break;
|
||||
}
|
||||
const finerRows = await readGranularity(
|
||||
userId,
|
||||
type,
|
||||
refined,
|
||||
from,
|
||||
to,
|
||||
priority,
|
||||
);
|
||||
if (!finerRows || finerRows.length === 0) break;
|
||||
granularity = refined;
|
||||
rows = finerRows;
|
||||
}
|
||||
const useSum = CUMULATIVE_HK_TYPES.has(type);
|
||||
annotate({
|
||||
action: { name: "measurement.list" },
|
||||
|
||||
Reference in new issue
Block a user