mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Give the tiered context its promised live fallback and un-truncate all-time stats
- The tiered AI context's band reader now falls back to a live grouped aggregate when a rollup band is empty (the module doc promised replace-with-fallback from day one; only the replace half existed), so a coverage-miss or never-folded YEAR partition no longer hands the model empty history. Far-from-UTC users get the day-grain band keyed on their own calendar (the same ±3h guard the correlation readers carry). - The slim analytics slice splices measurements older than the 5-year fold window into its all-time count/min/max/mean: the rollup tier only covers the window, so "all time" silently truncated for long histories while the live fallback told the truth. A type whose only rows are pre-fold no longer reads as never logged.
This commit is contained in:
1 parent
7611c82ae7
commit
4eeaec74aa
4 files changed
+451
-72
No files matched your search
@@ -99,6 +99,10 @@ beforeEach(() => {
|
||||
ROLLUP_FIND_MANY.mockResolvedValue([]);
|
||||
ROLLUP_FIND_FIRST.mockResolvedValue(null);
|
||||
MEASUREMENT_FIND_FIRST.mockResolvedValue(null);
|
||||
// v1.37.19 (A6-10) — default the tagged-template RAW reads (the coverage
|
||||
// probe + the pre-fold all-time remainder) to empty; individual tests
|
||||
// queue their own `mockResolvedValueOnce` sequences on top.
|
||||
RAW.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -404,8 +408,9 @@ describe("computeSummariesSlice", () => {
|
||||
|
||||
// 1 RAW coverage probe + 3 UNSAFE data queries (narrow aggregate
|
||||
// + latests + rollup GROUP BY; v1.4.37.2 — the prior `findMany`
|
||||
// is gone). No heavy aggregate.
|
||||
expect(RAW).toHaveBeenCalledTimes(1);
|
||||
// is gone). No heavy aggregate. v1.37.19 (A6-10) — the second RAW
|
||||
// call is the pre-fold all-time remainder splice.
|
||||
expect(RAW).toHaveBeenCalledTimes(2);
|
||||
expect(UNSAFE).toHaveBeenCalledTimes(3);
|
||||
// v1.4.40 W-WMY-WIRE — the year-ago baseline probe runs
|
||||
// `readBestGranularityRollups(userId, type, 395)` per
|
||||
@@ -418,6 +423,63 @@ describe("computeSummariesSlice", () => {
|
||||
// now runs to compose slope / r² / sd: 1 accumulator + 3 WMY = 4.
|
||||
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
// Watched red: with the pre-fold splice removed from the rollup path
|
||||
// (the pre-v1.37.19 assembly used the GROUP BY figures verbatim), the
|
||||
// count / min / max / mean assertions fail — an account with rows
|
||||
// older than the 5-year fold window had its "all-time" figures
|
||||
// silently truncated to the window while the live fallback reported
|
||||
// the true numbers.
|
||||
it("splices rows older than the fold window into the all-time figures (A6-10)", async () => {
|
||||
RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }]) // probe
|
||||
.mockResolvedValueOnce([
|
||||
// pre-fold remainder: 5 ancient readings, heavier and wider.
|
||||
{ type: "WEIGHT", count: 5, min: 70, max: 95, mean: 90 },
|
||||
]);
|
||||
UNSAFE.mockResolvedValueOnce([
|
||||
{ type: "WEIGHT", avg7: 82, avg30: 82.5, median: 82.1 },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ type: "WEIGHT", value: 82.7, measured_at: new Date() },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ type: "WEIGHT", count: 20, min: 79.5, max: 84.0, mean: 82.0 },
|
||||
]);
|
||||
|
||||
const result = await computeSummariesSlice("user-prefold");
|
||||
const weight = result.summaries.WEIGHT;
|
||||
|
||||
// 20 in-window + 5 pre-fold.
|
||||
expect(weight.count).toBe(25);
|
||||
// Envelope widens to the ancient extremes.
|
||||
expect(weight.min).toBe(70);
|
||||
expect(weight.max).toBe(95);
|
||||
// Weighted mean: (82*20 + 90*5) / 25 = 83.6.
|
||||
expect(weight.mean).toBe(83.6);
|
||||
// Windowed figures stay window-scoped.
|
||||
expect(weight.avg7).toBe(82);
|
||||
});
|
||||
|
||||
it("surfaces a type whose only rows are pre-fold (no DAY bucket)", async () => {
|
||||
RAW.mockResolvedValueOnce([
|
||||
{ type: "WEIGHT", has_buckets: true },
|
||||
]).mockResolvedValueOnce([
|
||||
{ type: "HEIGHT", count: 2, min: 180, max: 181, mean: 180.5 },
|
||||
]);
|
||||
UNSAFE.mockResolvedValueOnce([]) // narrows
|
||||
.mockResolvedValueOnce([]) // latests
|
||||
.mockResolvedValueOnce([]); // rollup GROUP BY
|
||||
|
||||
const result = await computeSummariesSlice("user-prefold-only");
|
||||
const height = result.summaries.HEIGHT;
|
||||
expect(height.count).toBe(2);
|
||||
expect(height.min).toBe(180);
|
||||
expect(height.max).toBe(181);
|
||||
expect(height.mean).toBe(180.5);
|
||||
// No in-window rows: every windowed field stays null.
|
||||
expect(height.avg7).toBeNull();
|
||||
expect(height.slope30).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -62,7 +62,10 @@ import { prisma } from "@/lib/db";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { ensureUserRollupsFresh } from "@/lib/rollups/measurement-rollups";
|
||||
import {
|
||||
ensureUserRollupsFresh,
|
||||
ROLLUP_FOLD_WINDOW_MS,
|
||||
} from "@/lib/rollups/measurement-rollups";
|
||||
import {
|
||||
collapseRollupRowsBySource,
|
||||
composeWindowedRegression,
|
||||
@@ -421,13 +424,22 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
|
||||
);
|
||||
const rankM = buildSourceRankCase(priorityJson, 'm."type"', 'm."source"');
|
||||
|
||||
const [narrows, latests, dayBuckets, accumulatorRows] = await Promise.all([
|
||||
// The FROM is restricted to the canonical-source rows per (type, day): the
|
||||
// inner DISTINCT ON picks the ladder-winning source for each day, and the
|
||||
// join keeps only that source's readings so the 90-day AVG / median / slope
|
||||
// never blend two devices that both reported the same vital.
|
||||
prisma.$queryRawUnsafe<NarrowAggregateRow[]>(
|
||||
`
|
||||
// v1.37.19 (A6-10) — where the fold window starts. DAY rollup buckets
|
||||
// only exist inside the trailing `ROLLUP_FOLD_WINDOW_MS` (5 y), so the
|
||||
// rollup-composed "all-time" figures silently truncated to that window
|
||||
// for accounts with older history, while the cold live path reported
|
||||
// the true unbounded numbers. The pre-fold remainder query below
|
||||
// splices the older rows back in.
|
||||
const foldWindowStart = new Date(Date.now() - ROLLUP_FOLD_WINDOW_MS);
|
||||
|
||||
const [narrows, latests, dayBuckets, accumulatorRows, preFoldRemainder] =
|
||||
await Promise.all([
|
||||
// The FROM is restricted to the canonical-source rows per (type, day): the
|
||||
// inner DISTINCT ON picks the ladder-winning source for each day, and the
|
||||
// join keeps only that source's readings so the 90-day AVG / median / slope
|
||||
// never blend two devices that both reported the same vital.
|
||||
prisma.$queryRawUnsafe<NarrowAggregateRow[]>(
|
||||
`
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
AVG(m."value") FILTER (
|
||||
@@ -448,13 +460,13 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
|
||||
FROM ${canonicalMeasurementsFrom(rankUnqualified, "90 days")}
|
||||
GROUP BY m."type"
|
||||
`,
|
||||
userId,
|
||||
),
|
||||
// v1.11.1 — the latest tile reflects the canonical source for the latest
|
||||
// day, matching the chart: order by latest day first, then the ladder rank
|
||||
// (canonical source wins), then the latest reading of that source.
|
||||
prisma.$queryRawUnsafe<LatestRow[]>(
|
||||
`
|
||||
userId,
|
||||
),
|
||||
// v1.11.1 — the latest tile reflects the canonical source for the latest
|
||||
// day, matching the chart: order by latest day first, then the ladder rank
|
||||
// (canonical source wins), then the latest reading of that source.
|
||||
prisma.$queryRawUnsafe<LatestRow[]>(
|
||||
`
|
||||
SELECT DISTINCT ON (m."type")
|
||||
m."type"::text AS type,
|
||||
m."value"::double precision AS value,
|
||||
@@ -464,24 +476,24 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
|
||||
AND m."deleted_at" IS NULL
|
||||
ORDER BY m."type", date_trunc('day', m."measured_at") DESC, (${rankM}), m."measured_at" DESC
|
||||
`,
|
||||
userId,
|
||||
),
|
||||
// v1.4.37.2 hotfix — the v1.4.35 implementation read EVERY DAY
|
||||
// rollup bucket for the user (`findMany` without a `bucketStart`
|
||||
// window) and then composed `count / min / max / mean` in JS.
|
||||
// On tenants with large measurement partitions that materialised
|
||||
// as a six-figure row transfer + a JS loop = multi-second per
|
||||
// cache miss, even with the rollup table hot. The slim slice's
|
||||
// contract is the all-time count / min / max / mean per type —
|
||||
// exactly what a SQL `GROUP BY type` returns in a single
|
||||
// round-trip.
|
||||
//
|
||||
// v1.11.1 — rows are now per source. Collapse each (type, day) to the
|
||||
// ladder-canonical source via DISTINCT ON before the all-time aggregate,
|
||||
// so a dual-source vital is counted once. Still one server-side pass —
|
||||
// the DISTINCT ON + GROUP BY runs in Postgres and returns one row/type.
|
||||
prisma.$queryRawUnsafe<RollupAggregateRow[]>(
|
||||
`
|
||||
userId,
|
||||
),
|
||||
// v1.4.37.2 hotfix — the v1.4.35 implementation read EVERY DAY
|
||||
// rollup bucket for the user (`findMany` without a `bucketStart`
|
||||
// window) and then composed `count / min / max / mean` in JS.
|
||||
// On tenants with large measurement partitions that materialised
|
||||
// as a six-figure row transfer + a JS loop = multi-second per
|
||||
// cache miss, even with the rollup table hot. The slim slice's
|
||||
// contract is the all-time count / min / max / mean per type —
|
||||
// exactly what a SQL `GROUP BY type` returns in a single
|
||||
// round-trip.
|
||||
//
|
||||
// v1.11.1 — rows are now per source. Collapse each (type, day) to the
|
||||
// ladder-canonical source via DISTINCT ON before the all-time aggregate,
|
||||
// so a dual-source vital is counted once. Still one server-side pass —
|
||||
// the DISTINCT ON + GROUP BY runs in Postgres and returns one row/type.
|
||||
prisma.$queryRawUnsafe<RollupAggregateRow[]>(
|
||||
`
|
||||
WITH collapsed AS (
|
||||
SELECT DISTINCT ON ("type", "bucket_start")
|
||||
"type" AS type,
|
||||
@@ -506,37 +518,81 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
|
||||
FROM collapsed
|
||||
GROUP BY "type"
|
||||
`,
|
||||
userId,
|
||||
),
|
||||
// v1.20.0 F6 — trailing 90-day DAY rollup rows carrying the per-bucket
|
||||
// regression accumulators. The windowed slope / r² / sd compose from
|
||||
// these (composeWindowedRegression) instead of the live REGR_* scan the
|
||||
// narrow query used to run. Rows are per source; collapsed per type
|
||||
// below so a dual-source vital contributes only its canonical source's
|
||||
// accumulators. Bounded to 90 days (≤ ~90 rows/type), the same window
|
||||
// the dropped slope columns covered.
|
||||
prisma.measurementRollup.findMany({
|
||||
where: {
|
||||
userId,
|
||||
granularity: "DAY",
|
||||
bucketStart: {
|
||||
gte: startOfUtcDay(new Date(Date.now() - 90 * DAY_MS)),
|
||||
),
|
||||
// v1.20.0 F6 — trailing 90-day DAY rollup rows carrying the per-bucket
|
||||
// regression accumulators. The windowed slope / r² / sd compose from
|
||||
// these (composeWindowedRegression) instead of the live REGR_* scan the
|
||||
// narrow query used to run. Rows are per source; collapsed per type
|
||||
// below so a dual-source vital contributes only its canonical source's
|
||||
// accumulators. Bounded to 90 days (≤ ~90 rows/type), the same window
|
||||
// the dropped slope columns covered.
|
||||
prisma.measurementRollup.findMany({
|
||||
where: {
|
||||
userId,
|
||||
granularity: "DAY",
|
||||
bucketStart: {
|
||||
gte: startOfUtcDay(new Date(Date.now() - 90 * DAY_MS)),
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ type: "asc" }, { bucketStart: "asc" }],
|
||||
select: {
|
||||
type: true,
|
||||
source: true,
|
||||
bucketStart: true,
|
||||
count: true,
|
||||
mean: true,
|
||||
sumX: true,
|
||||
sumXy: true,
|
||||
sumXx: true,
|
||||
sumYy: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
orderBy: [{ type: "asc" }, { bucketStart: "asc" }],
|
||||
select: {
|
||||
type: true,
|
||||
source: true,
|
||||
bucketStart: true,
|
||||
count: true,
|
||||
mean: true,
|
||||
sumX: true,
|
||||
sumXy: true,
|
||||
sumXx: true,
|
||||
sumYy: true,
|
||||
},
|
||||
}),
|
||||
// v1.37.19 (A6-10) — all-time remainder OLDER than the fold window.
|
||||
// One grouped aggregate over the pre-fold rows (index range scan on
|
||||
// `(user_id, measured_at)`; empty for any account younger than the
|
||||
// window). Spliced into the per-type figures below so "all-time"
|
||||
// means all time on the rollup path too. Deliberately without the
|
||||
// source-priority collapse: pre-fold history predates the rollup
|
||||
// tier's per-day canonicalisation, and a per-day DISTINCT ON over a
|
||||
// multi-year span is the cost this splice exists to avoid — for
|
||||
// count/min/max/mean over years-old data the raw figures are the
|
||||
// honest ones.
|
||||
prisma.$queryRaw<
|
||||
Array<{
|
||||
type: string;
|
||||
count: number;
|
||||
min: number;
|
||||
max: number;
|
||||
mean: number;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
m."type"::text AS type,
|
||||
COUNT(*)::int AS count,
|
||||
MIN(m."value")::double precision AS min,
|
||||
MAX(m."value")::double precision AS max,
|
||||
AVG(m."value")::double precision AS mean
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."measured_at" < ${foldWindowStart}
|
||||
GROUP BY m."type"
|
||||
`,
|
||||
]);
|
||||
|
||||
const preFoldByType = new Map<
|
||||
string,
|
||||
{ count: number; min: number; max: number; mean: number }
|
||||
>();
|
||||
for (const row of preFoldRemainder) {
|
||||
preFoldByType.set(row.type, {
|
||||
count: Number(row.count),
|
||||
min: Number(row.min),
|
||||
max: Number(row.max),
|
||||
mean: Number(row.mean),
|
||||
});
|
||||
}
|
||||
|
||||
// v1.20.0 F6 — collapse the 90-day accumulator rows to the canonical
|
||||
// source per (type, day), then build a per-type accumulator-bucket list so
|
||||
@@ -653,12 +709,24 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
|
||||
}
|
||||
const reg7 = composeWindowedRegression(accBuckets, since7);
|
||||
const reg30 = composeWindowedRegression(accBuckets, since30);
|
||||
// v1.37.19 (A6-10) — splice the pre-fold remainder into the all-time
|
||||
// figures: the rollup tier only covers the trailing fold window, so
|
||||
// count / min / max / mean composed from it alone truncated "all
|
||||
// time" to 5 y. Windowed fields (avg7/30, median, slopes) are
|
||||
// unaffected — their windows sit far inside the fold window.
|
||||
const pre = preFoldByType.get(row.type);
|
||||
const allCount = row.count + (pre?.count ?? 0);
|
||||
const allMin = pre ? Math.min(row.min, pre.min) : row.min;
|
||||
const allMax = pre ? Math.max(row.max, pre.max) : row.max;
|
||||
const allMean = pre
|
||||
? (row.mean * row.count + pre.mean * pre.count) / allCount
|
||||
: row.mean;
|
||||
summaries[row.type] = {
|
||||
count: row.count,
|
||||
count: allCount,
|
||||
latest,
|
||||
min: round2(row.min),
|
||||
max: round2(row.max),
|
||||
mean: round2(row.mean),
|
||||
min: round2(allMin),
|
||||
max: round2(allMax),
|
||||
mean: round2(allMean),
|
||||
median: round2(narrow?.median ?? null),
|
||||
avg7: round2(narrow?.avg7 ?? null),
|
||||
avg30: round2(narrow?.avg30 ?? null),
|
||||
@@ -667,6 +735,31 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
|
||||
avg30LastMonth: round2(narrow?.avg30_last_month ?? null),
|
||||
avg30LastYear: null,
|
||||
};
|
||||
if (pre) totalRows += pre.count;
|
||||
}
|
||||
|
||||
// v1.37.19 (A6-10) — a type whose ONLY rows are older than the fold
|
||||
// window has no DAY bucket at all; without this arm it read as "never
|
||||
// logged" on the rollup path while the live path reported its history.
|
||||
for (const [type, pre] of preFoldByType) {
|
||||
if (typesWithData.includes(type)) continue;
|
||||
typeCount += 1;
|
||||
totalRows += pre.count;
|
||||
typesWithData.push(type);
|
||||
summaries[type] = {
|
||||
count: pre.count,
|
||||
latest: latestByType.get(type) ?? null,
|
||||
min: round2(pre.min),
|
||||
max: round2(pre.max),
|
||||
mean: round2(pre.mean),
|
||||
median: null,
|
||||
avg7: null,
|
||||
avg30: null,
|
||||
slope7: null,
|
||||
slope30: null,
|
||||
avg30LastMonth: null,
|
||||
avg30LastYear: null,
|
||||
};
|
||||
}
|
||||
|
||||
// v1.4.40 W-WMY-WIRE — populate `avg30LastYear` per type from the
|
||||
|
||||
@@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({
|
||||
ensureUserRollupsFresh: vi.fn(),
|
||||
probeRollupCoverage: vi.fn(),
|
||||
measurementFindMany: vi.fn(),
|
||||
queryRaw: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../measurement-rollups", () => ({
|
||||
@@ -34,6 +35,8 @@ vi.mock("../measurement-coverage", () => ({
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: mocks.measurementFindMany },
|
||||
// v1.37.19 (A6-11) — the live band fallback.
|
||||
$queryRaw: mocks.queryRaw,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -70,6 +73,7 @@ describe("buildTieredSeries", () => {
|
||||
mocks.probeRollupCoverage.mockResolvedValue(new Map([["WEIGHT", true]]));
|
||||
mocks.measurementFindMany.mockResolvedValue([]);
|
||||
mocks.readRollupBuckets.mockResolvedValue([]);
|
||||
mocks.queryRaw.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -243,3 +247,97 @@ describe("buildTieredSeries", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// v1.37.19 (A6-11) — the live fallback the module doc promised from day
|
||||
// one, and the far-from-UTC day-grain guard.
|
||||
//
|
||||
// Watched red: with the `readBandLive` fallback removed from `readBand`
|
||||
// (the pre-fix reader returned the empty rollup result as-is), the
|
||||
// coverage-miss case fails — a >5y account whose YEAR partition was never
|
||||
// folded handed the AI empty MONTH/YEAR bands as if the history did not
|
||||
// exist. With the `isNearUtc` guard removed, the far-tz case fails — the
|
||||
// DAY band read UTC-keyed rollup buckets for a user whose local calendar
|
||||
// disagrees with UTC for most of the day.
|
||||
describe("buildTieredSeries — live fallback + near-UTC guard (A6-11)", () => {
|
||||
beforeEach(() => {
|
||||
mocks.ensureUserRollupsFresh.mockResolvedValue({ recomputed: false });
|
||||
mocks.probeRollupCoverage.mockResolvedValue(new Map([["WEIGHT", true]]));
|
||||
mocks.measurementFindMany.mockResolvedValue([]);
|
||||
mocks.readRollupBuckets.mockResolvedValue([]);
|
||||
mocks.queryRaw.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("serves an empty rollup band from the live aggregate instead of silence", async () => {
|
||||
mocks.readRollupBuckets.mockResolvedValue([]);
|
||||
mocks.queryRaw.mockResolvedValue([
|
||||
{
|
||||
bucket_start: new Date("2025-01-01T00:00:00.000Z"),
|
||||
count: 40,
|
||||
mean: 82.1,
|
||||
min_value: 78,
|
||||
max_value: 90,
|
||||
sd: 2.5,
|
||||
},
|
||||
]);
|
||||
|
||||
const series = await buildTieredSeries("u1", "WEIGHT", {
|
||||
now: NOW,
|
||||
tz: "Europe/Berlin",
|
||||
});
|
||||
|
||||
// Every band read fell through to the live aggregate (4 bands).
|
||||
expect(mocks.queryRaw).toHaveBeenCalled();
|
||||
expect(series.yearBand.length).toBeGreaterThan(0);
|
||||
expect(series.yearBand[0]).toMatchObject({ mean: 82.1, min: 78, max: 90 });
|
||||
});
|
||||
|
||||
it("keeps the rollup result when the band has buckets (no live read)", async () => {
|
||||
mocks.readRollupBuckets.mockResolvedValue([
|
||||
rollupRow("2026-05-01T00:00:00.000Z", 80, 79, 81, 10),
|
||||
]);
|
||||
|
||||
await buildTieredSeries("u1", "WEIGHT", {
|
||||
now: NOW,
|
||||
tz: "Europe/Berlin",
|
||||
});
|
||||
expect(mocks.queryRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes the DAY band straight to the tz-keyed live path for far-from-UTC users", async () => {
|
||||
await buildTieredSeries("u1", "WEIGHT", {
|
||||
now: NOW,
|
||||
tz: "Pacific/Auckland",
|
||||
});
|
||||
|
||||
// The DAY grain never touched the UTC-keyed rollup tier …
|
||||
const dayReads = mocks.readRollupBuckets.mock.calls.filter(
|
||||
(c) => c[2] === "DAY",
|
||||
);
|
||||
expect(dayReads).toEqual([]);
|
||||
// … while the coarser bands still did.
|
||||
const coarseReads = mocks.readRollupBuckets.mock.calls.map(
|
||||
(c) => c[2] as RollupGranularity,
|
||||
);
|
||||
expect(coarseReads).toEqual(
|
||||
expect.arrayContaining(["WEEK", "MONTH", "YEAR"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("still reads the DAY rollup inside the near-UTC band", async () => {
|
||||
mocks.readRollupBuckets.mockResolvedValue([
|
||||
rollupRow("2026-06-01T00:00:00.000Z", 80, 79, 81, 10),
|
||||
]);
|
||||
await buildTieredSeries("u1", "WEIGHT", {
|
||||
now: NOW,
|
||||
tz: "Europe/Berlin",
|
||||
});
|
||||
const dayReads = mocks.readRollupBuckets.mock.calls.filter(
|
||||
(c) => c[2] === "DAY",
|
||||
);
|
||||
expect(dayReads.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,7 @@ import type {
|
||||
MeasurementType,
|
||||
RollupGranularity,
|
||||
} from "@/generated/prisma/client";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { isNearUtc, userDayKey } from "@/lib/tz/format";
|
||||
import {
|
||||
ensureUserRollupsFresh,
|
||||
readRollupBuckets,
|
||||
@@ -278,6 +278,116 @@ async function readRecentDaily(
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The `date_trunc` unit per band — a CLOSED map over the granularity enum,
|
||||
* which is what makes splicing `unit` into the raw SQL below a
|
||||
* whitelist-splice, not an injection surface.
|
||||
*/
|
||||
const BAND_TRUNC_UNIT: Record<
|
||||
RollupGranularity,
|
||||
"day" | "week" | "month" | "year"
|
||||
> = {
|
||||
DAY: "day",
|
||||
WEEK: "week",
|
||||
MONTH: "month",
|
||||
YEAR: "year",
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.37.19 (A6-11) — LIVE aggregate for one band, the fallback the module
|
||||
* doc has promised since v1.18.7 but `readBand` never had: a coverage
|
||||
* miss (or a >5y account whose YEAR partition was never folded) used to
|
||||
* hand the AI an empty MONTH/YEAR band as if the history did not exist.
|
||||
*
|
||||
* One grouped aggregate over raw rows, shaped like a rollup bucket. Two
|
||||
* documented approximations against the folded tier: no source-priority
|
||||
* collapse (a dual-source day counts both readings into the mean), and
|
||||
* no slope/r² (left null, exactly like a pre-0190 bucket). Both are
|
||||
* acceptable for prompt context — the alternative was silence.
|
||||
*
|
||||
* `tzKey` (the far-from-UTC day-grain guard) buckets on the user's LOCAL
|
||||
* calendar via `AT TIME ZONE`; parameter-bound, never spliced.
|
||||
*/
|
||||
async function readBandLive(
|
||||
userId: string,
|
||||
type: MeasurementType,
|
||||
granularity: RollupGranularity,
|
||||
from: Date,
|
||||
to: Date,
|
||||
tzKey?: string,
|
||||
): Promise<RollupBucket[]> {
|
||||
const unit = BAND_TRUNC_UNIT[granularity];
|
||||
try {
|
||||
const rows = tzKey
|
||||
? await prisma.$queryRaw<
|
||||
Array<{
|
||||
bucket_start: Date;
|
||||
count: number;
|
||||
mean: number;
|
||||
min_value: number;
|
||||
max_value: number;
|
||||
sd: number | null;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
date_trunc(${unit}, m."measured_at" AT TIME ZONE ${tzKey}) AS bucket_start,
|
||||
COUNT(*)::int AS count,
|
||||
AVG(m."value")::double precision AS mean,
|
||||
MIN(m."value")::double precision AS min_value,
|
||||
MAX(m."value")::double precision AS max_value,
|
||||
STDDEV_POP(m."value")::double precision AS sd
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."type" = ${type}::"measurement_type"
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."measured_at" >= ${from}
|
||||
AND m."measured_at" < ${to}
|
||||
GROUP BY 1
|
||||
ORDER BY 1
|
||||
`
|
||||
: await prisma.$queryRaw<
|
||||
Array<{
|
||||
bucket_start: Date;
|
||||
count: number;
|
||||
mean: number;
|
||||
min_value: number;
|
||||
max_value: number;
|
||||
sd: number | null;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
date_trunc(${unit}, m."measured_at") AS bucket_start,
|
||||
COUNT(*)::int AS count,
|
||||
AVG(m."value")::double precision AS mean,
|
||||
MIN(m."value")::double precision AS min_value,
|
||||
MAX(m."value")::double precision AS max_value,
|
||||
STDDEV_POP(m."value")::double precision AS sd
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."type" = ${type}::"measurement_type"
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."measured_at" >= ${from}
|
||||
AND m."measured_at" < ${to}
|
||||
GROUP BY 1
|
||||
ORDER BY 1
|
||||
`;
|
||||
return rows.map((r) => ({
|
||||
bucketStart: new Date(r.bucket_start),
|
||||
count: Number(r.count),
|
||||
mean: Number(r.mean),
|
||||
minValue: Number(r.min_value),
|
||||
maxValue: Number(r.max_value),
|
||||
sd: r.sd === null ? null : Number(r.sd),
|
||||
slope: null,
|
||||
r2: null,
|
||||
}));
|
||||
} catch {
|
||||
// The band builder never throws — an unreadable band degrades to
|
||||
// empty exactly like the rollup path always has.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function readBand(
|
||||
userId: string,
|
||||
type: MeasurementType,
|
||||
@@ -285,13 +395,28 @@ async function readBand(
|
||||
fromDaysAgo: number,
|
||||
toDaysAgo: number,
|
||||
now: number,
|
||||
tz?: string,
|
||||
): Promise<RollupBucket[]> {
|
||||
const from = new Date(now - fromDaysAgo * DAY_MS);
|
||||
const to = new Date(now - toDaysAgo * DAY_MS);
|
||||
// v1.37.19 (A6-11) — the far-from-UTC guard the correlation readers
|
||||
// carry (v1.4.38 W-A class): DAY rollup buckets group by UTC day, which
|
||||
// diverges from the user's local calendar for most of the day outside
|
||||
// the ±3 h near-UTC band. For those users the day-grain band goes
|
||||
// straight to the live path keyed in their zone. The coarser bands stay
|
||||
// on the UTC-anchored rollups — a few hours of offset moves bucket
|
||||
// MEMBERSHIP at week/month/year grain by at most one edge reading.
|
||||
if (granularity === "DAY" && tz && !isNearUtc(tz, new Date(now))) {
|
||||
return readBandLive(userId, type, granularity, from, to, tz);
|
||||
}
|
||||
// `readRollupBuckets` never throws — an empty / missing partition yields
|
||||
// an empty array, which is the "fall back to nothing for this band"
|
||||
// behaviour the coverage miss path relies on.
|
||||
return readRollupBuckets(userId, type, granularity, from, to);
|
||||
// an empty array. v1.37.19 — an empty band now falls back to the live
|
||||
// aggregate instead of silently handing the AI nothing (the module doc
|
||||
// promised replace-with-fallback from day one; only the "replace" half
|
||||
// existed).
|
||||
const buckets = await readRollupBuckets(userId, type, granularity, from, to);
|
||||
if (buckets.length > 0) return buckets;
|
||||
return readBandLive(userId, type, granularity, from, to);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -331,6 +456,7 @@ export async function buildTieredSeries(
|
||||
TIERED_BANDS.dayUntil,
|
||||
TIERED_BANDS.rawDays,
|
||||
now,
|
||||
options.tz,
|
||||
),
|
||||
coarseOnly
|
||||
? emptyBand
|
||||
|
||||
Reference in new issue
Block a user