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:
Marc-André Bombeck committed 2026-08-14 06:24:37 +02:00
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();
});
});
/**
+159 -66
View File
@@ -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);
});
});
+130 -4
View File
@@ -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