fix(analytics): withhold a trend slope when the window's readings cover too few days

A least-squares slope per day over readings minutes apart explodes: three
blood-pressure readings in one sitting, the only ones of the week, fitted
thousands of mmHg per day, and the dashboard tile printed seven days of it
as the 7-day trend. The 30-day fallback had the same flaw.

A window now earns a slope only when its readings fall on at least three
UTC days whose first and last lie at least half the window apart (4 days
for the 7-day window, 15 for the 30-day one). The rule lives once in
window-confidence.ts and gates every path that publishes a windowed trend:
trendSlope (summarize and its callers), the rollup composition and the
live REGR_SLOPE fallback of the summaries slice and the comprehensive
aggregator, and the per-bucket slope of folded week, month and year
buckets. Slope, direction and confidence are present or absent together.
The fit itself stays over individual readings, as the window means do.

The parity snapshots change only where the fixture falls short of the
rule: a weekly body-fat series has two days in its 7-day window, and a
ten-day oxygen-saturation series spans nine days of its 30-day window.
This commit is contained in:
Marc-André Bombeck committed 2026-10-24 11:13:25 +02:00
1 parent 5a8f59e0c0
commit c15ff8401b
32 files changed
+850 -247

No files matched your search

+4 -2
View File
@@ -66753,9 +66753,11 @@ components:
- direction
- confidence
additionalProperties: false
description: A linear trend over a trailing window. The window is anchored on NOW, not on the newest reading, so a
description: "A linear trend over a trailing window. The window is anchored on NOW, not on the newest reading, so a
series that stopped weeks ago reports `null` for the slope instead of describing a trend that has not been
observed since.
observed since. It is also `null` when the window's readings fall on fewer than 3 calendar days (UTC) or when
their first and last day lie less than half the window apart (4 days for the 7-day window, 15 for the 30-day
one): readings minutes apart cannot carry a slope per day."
AnalyticsCorrelations:
type: object
properties:
@@ -1,7 +1,7 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 89,
"total": 99483,
"total": 99450,
"entries": {
"app/medications/page-client.tsx": 839,
"components/charts/mood-chart.tsx": 1166,
@@ -35,7 +35,7 @@
"lib/ai/provider.ts": 1632,
"lib/analytics/score/reader.ts": 998,
"lib/analytics/sleep-night.ts": 1345,
"lib/analytics/summaries-slice.ts": 1248,
"lib/analytics/summaries-slice.ts": 1231,
"lib/auth/session.ts": 811,
"lib/cache/server-cache.ts": 848,
"lib/cycle/prediction.ts": 883,
@@ -58,7 +58,7 @@
"lib/illness/correlation.ts": 1059,
"lib/import/health-connect/import.ts": 1689,
"lib/import/unzip-export-xml.ts": 825,
"lib/insights/comprehensive-aggregator.ts": 943,
"lib/insights/comprehensive-aggregator.ts": 927,
"lib/insights/comprehensive-generate.ts": 1674,
"lib/insights/correlation-channel-series.ts": 1339,
"lib/insights/correlation-discovery.ts": 1240,
@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 352,
"total": 113991,
"total": 113987,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
@@ -262,7 +262,7 @@
"lib/analytics/correlations-fast-path.ts :: computeCorrelationHypothesesFastPath": 256,
"lib/analytics/intraday-pulse-io.ts :: loadIntradayPulse": 206,
"lib/analytics/score/reader.ts :: computeUserHealthScore": 324,
"lib/analytics/summaries-slice.ts :: computeFromRollups": 377,
"lib/analytics/summaries-slice.ts :: computeFromRollups": 375,
"lib/api-handler.ts :: apiHandler": 207,
"lib/api-handler.ts :: apiHandler > wrapped": 201,
"lib/auth/profile-update.ts :: applyProfileUpdate": 353,
@@ -295,7 +295,7 @@
"lib/illness/correlation.ts :: computeIllnessCorrelation": 233,
"lib/import/labs-csv.ts :: importLabsCsv": 309,
"lib/import/labs-csv.ts :: importLabsCsv > flush": 231,
"lib/insights/comprehensive-aggregator.ts :: buildFromRollups": 220,
"lib/insights/comprehensive-aggregator.ts :: buildFromRollups": 218,
"lib/insights/comprehensive-generate.ts :: generateComprehensiveInsight": 876,
"lib/insights/features.ts :: extractFeatures": 796,
"lib/insights/mood-aggregates.ts :: fetchMoodAggregates": 246,
@@ -333,6 +333,11 @@ describe("GET /api/analytics", () => {
slope30: -0.02,
r2_30: 0.3,
r2_90: 0.2,
// Twelve readings on twelve days across the month.
days7: 3,
span7: 5,
days30: 12,
span30: 27,
},
] as never)
.mockResolvedValueOnce([
@@ -5,6 +5,8 @@ import { Activity, Smile } from "lucide-react";
import { TrendCard } from "../trend-card";
import { I18nProvider } from "@/lib/i18n/context";
import { primeLocales } from "@/__tests__/helpers/prime-locales";
import { summarize } from "@/lib/analytics/trends";
import { summaryToTrend7Delta } from "@/lib/analytics/trend-delta";
primeLocales("de");
@@ -166,3 +168,42 @@ describe("<TrendCard> 7-day trend label", () => {
expect(html).not.toContain("text-warning");
});
});
/**
* The blood-pressure tile read "7-Tage-Trend: 125 (−11.412)" when the
* week's only readings were one sitting minutes apart: the regression's
* slope per day exploded and the tile printed seven days of it. The tile is
* fed the way `strip-tiles.tsx` feeds it, from `summarize()` through
* `summaryToTrend7Delta()`, so the delta it renders is the one the
* dashboard would.
*/
describe("<TrendCard> blood-pressure tile after one sitting", () => {
it("renders the 7-day mean, not a thousands-of-mmHg trend", () => {
const MINUTE = 60 * 1000;
const summary = summarize([
{ date: new Date(Date.now() - 10 * MINUTE), value: 130 },
{ date: new Date(Date.now() - 8 * MINUTE), value: 124 },
{ date: new Date(Date.now() - 6 * MINUTE), value: 118 },
]);
const html = render(
<TrendCard
label="BD (SYS)"
latest={summary.latest}
unit="mmHg"
fractionDigits={0}
avg7={summary.avg7}
avg30={summary.avg30}
slope30={summary.slope30}
trend7Delta={summaryToTrend7Delta(summary)}
icon={Activity}
directionSentiment="up-bad"
/>,
"de",
);
expect(html).not.toContain('data-slot="trend7-delta"');
expect(html).not.toContain("7-Tage-Trend");
expect(html).toContain("7T");
// No grouped thousands anywhere on the tile.
expect(html).not.toMatch(/\d\.\d{3}/);
});
});
@@ -174,6 +174,12 @@ describe("computeSummariesSlice", () => {
r2_30: 0.42,
slope90: 0.001,
r2_90: 0.12,
// Readings on 6 days spanning 6 (7-day window) and on 20 days
// spanning 29 (30-day window): both windows earn a trend.
days7: 6,
span7: 6,
days30: 20,
span30: 29,
},
])
.mockResolvedValueOnce([
@@ -354,37 +360,32 @@ describe("computeSummariesSlice", () => {
]);
// v1.20.0 — the 90-day accumulator findMany (first measurementRollup
// .findMany in the Promise.all). Two single-reading DAY buckets one day
// apart inside the 7-day window: x = epoch-days, y = 82.00 then 82.02.
// composeWindowedRegression over them yields slope = 0.02/day with a
// perfect fit (r² = 1) — a deterministic, hand-checkable regression.
const dayB = startOfUtcDay(new Date());
const dayA = startOfUtcDay(new Date(Date.now() - 24 * 60 * 60 * 1000));
const xA = dayA.getTime() / 86_400_000;
const xB = dayB.getTime() / 86_400_000;
// .findMany in the Promise.all). Three single-reading DAY buckets six
// and three days apart inside the 7-day window: x = epoch-days, y =
// 82.00, 82.06, 82.12. composeWindowedRegression over them yields
// slope = 0.02/day with a perfect fit (r² = 1) — a deterministic,
// hand-checkable regression over enough days to carry a trend.
const day = (daysAgo: number) =>
startOfUtcDay(new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000));
const bucket = (daysAgo: number, value: number) => {
const bucketStart = day(daysAgo);
const x = bucketStart.getTime() / 86_400_000;
return {
type: "WEIGHT",
source: "MANUAL",
bucketStart,
count: 1,
mean: value,
sumX: x,
sumXy: x * value,
sumXx: x * x,
sumYy: value * value,
};
};
ROLLUP_FIND_MANY.mockResolvedValueOnce([
{
type: "WEIGHT",
source: "MANUAL",
bucketStart: dayA,
count: 1,
mean: 82.0,
sumX: xA,
sumXy: xA * 82.0,
sumXx: xA * xA,
sumYy: 82.0 * 82.0,
},
{
type: "WEIGHT",
source: "MANUAL",
bucketStart: dayB,
count: 1,
mean: 82.02,
sumX: xB,
sumXy: xB * 82.02,
sumXx: xB * xB,
sumYy: 82.02 * 82.02,
},
bucket(6, 82.0),
bucket(3, 82.06),
bucket(0, 82.12),
]);
const result = await computeSummariesSlice("user-rollup");
@@ -400,7 +401,7 @@ describe("computeSummariesSlice", () => {
expect(weight.latest).toBe(82.7);
expect(weight.avg7).toBe(82);
// v1.20.0 — slope composed from the accumulator buckets: a clean
// +0.02/day with a perfect two-point fit.
// +0.02/day with a perfect three-day fit.
expect(weight.slope7).toEqual({
slope: 0.02,
direction: "up",
@@ -630,6 +631,106 @@ describe("computeSummariesSlice", () => {
});
});
/**
* A window whose only readings sit minutes apart must not publish a
* slope per day. The dashboard blood-pressure tile printed
* "7-Tage-Trend: 125 (−11.412)" from three readings taken in one sitting:
* the regression's x-span was minutes, so the slope per day exploded and
* the tile multiplied it by seven. Both read paths withhold it.
*/
describe("trend evidence", () => {
it("withholds the warm-path slope when the window's readings are minutes apart on one day", async () => {
RAW.mockResolvedValueOnce([
{ type: "BLOOD_PRESSURE_SYS", has_buckets: true },
]);
UNSAFE.mockResolvedValueOnce([
{ type: "BLOOD_PRESSURE_SYS", avg7: 124, avg30: 124, median: 124 },
])
.mockResolvedValueOnce([
{ type: "BLOOD_PRESSURE_SYS", value: 124, measured_at: new Date() },
])
.mockResolvedValueOnce([
{
type: "BLOOD_PRESSURE_SYS",
count: 3,
days: 1,
min: 118,
max: 130,
mean: 124,
},
]);
// One DAY bucket holding three readings 2 and 4 minutes apart:
// 130, 124, 118 mmHg. The composed fit is −1.5 mmHg per minute,
// about −2,160 mmHg per day, about −15,000 projected onto 7 days.
const bucketStart = startOfUtcDay(new Date());
const base = bucketStart.getTime() / 86_400_000 + 0.25;
const minute = 1 / (24 * 60);
const xs = [base, base + 2 * minute, base + 4 * minute];
const ys = [130, 124, 118];
ROLLUP_FIND_MANY.mockResolvedValueOnce([
{
type: "BLOOD_PRESSURE_SYS",
source: "MANUAL",
bucketStart,
count: 3,
mean: 124,
sumValue: 372,
sumX: xs.reduce((a, x) => a + x, 0),
sumXy: xs.reduce((a, x, i) => a + x * ys[i], 0),
sumXx: xs.reduce((a, x) => a + x * x, 0),
sumYy: ys.reduce((a, y) => a + y * y, 0),
},
]);
const result = await computeSummariesSlice("user-cluster");
const sys = result.summaries.BLOOD_PRESSURE_SYS;
expect(sys.avg7).toBe(124);
expect(sys.slope7).toBeNull();
expect(sys.slope30).toBeNull();
});
it("withholds the cold-path slope on the evidence columns, not on the SQL slope", async () => {
RAW.mockResolvedValueOnce([
{ type: "BLOOD_PRESSURE_SYS", has_buckets: false },
]);
UNSAFE.mockResolvedValueOnce([
{
type: "BLOOD_PRESSURE_SYS",
count: BigInt(5),
min_value: 118,
max_value: 130,
mean_value: 124,
},
])
.mockResolvedValueOnce([
{
type: "BLOOD_PRESSURE_SYS",
avg7: 124,
avg30: 126,
// REGR_SLOPE over three readings minutes apart.
slope7: -2160,
r2_7: 1,
// The 30-day window adds two readings on two more days, but
// they span only 9 days: too little of the window for a trend.
slope30: -1.2,
r2_30: 0.4,
days7: 1,
span7: 0,
days30: 3,
span30: 9,
},
])
.mockResolvedValueOnce([
{ type: "BLOOD_PRESSURE_SYS", value: 124, measured_at: new Date() },
]);
const result = await computeSummariesSlice("user-cluster-cold");
const sys = result.summaries.BLOOD_PRESSURE_SYS;
expect(sys.slope7).toBeNull();
expect(sys.slope30).toBeNull();
});
});
/**
* A2-M2 — read-swap boundary-day consistency. The warm
* `computeFromRollups` path windows the regression on UTC-midnight
@@ -659,8 +760,10 @@ describe("computeSummariesSlice", () => {
const sql = windowedSql as string;
// Every regression window (7/30 for slope + r²) anchors on the
// day-truncated UTC boundary. Four FILTERs total (the 90-day pair
// left with slope90 in v1.37.19).
// day-truncated UTC boundary. Four regression FILTERs (the 90-day
// pair left with slope90 in v1.37.19) plus three per window for the
// trend evidence (distinct days, first day, last day), which must
// read the same rows the regression fits.
for (const days of ["7 days", "30 days"]) {
expect(sql).toContain(
`m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '${days}')`,
@@ -669,7 +772,7 @@ describe("computeSummariesSlice", () => {
const anchored = sql.match(
/date_trunc\('day', NOW\(\) AT TIME ZONE 'UTC'\) - INTERVAL '(?:7|30) days'\)/g,
);
expect(anchored?.length).toBe(4);
expect(anchored?.length).toBe(10);
// The bound stays a naive UTC timestamp like measured_at: converting
// it to timestamptz would route the comparison through the session
// time zone.
+123
View File
@@ -6,6 +6,7 @@ import {
wireSummary,
type DataPoint,
} from "../trends";
import { summaryToTrend7Delta } from "../trend-delta";
function makePoints(values: number[], startDaysAgo = 30): DataPoint[] {
const now = Date.now();
@@ -47,6 +48,128 @@ describe("trendSlope", () => {
});
});
/**
* A window earns a trend only when its readings cover enough days. A
* dashboard blood-pressure tile read "7-Tage-Trend: 125 (−11.412)" because
* the only readings of the week were taken minutes apart: the fit's x-span
* was minutes, the slope per day exploded, and the tile multiplied it by 7.
*/
describe("trendSlope minimum evidence", () => {
const NOW = Date.UTC(2026, 9, 24, 18, 0, 0);
const MINUTE = 60 * 1000;
const DAY = 24 * 60 * MINUTE;
const at = (msAgo: number, value: number): DataPoint => ({
date: new Date(NOW - msAgo),
value,
});
it("withholds the slope when the readings are minutes apart", () => {
// Three readings in one sitting this morning: 130, 124, 118 mmHg.
const sitting = [
at(10 * MINUTE, 130),
at(8 * MINUTE, 124),
at(6 * MINUTE, 118),
];
expect(trendSlope(sitting, 7, NOW)).toBeNull();
expect(trendSlope(sitting, 30, NOW)).toBeNull();
});
it("withholds the slope across midnight even though two days are touched", () => {
const today = Math.floor(NOW / DAY) * DAY;
const straddle = [
{ date: new Date(today - 2 * MINUTE), value: 130 },
{ date: new Date(today + 2 * MINUTE), value: 118 },
];
expect(trendSlope(straddle, 7, NOW)).toBeNull();
});
it("withholds the slope for two readings on two days", () => {
// Two days five days apart: a line through two points says nothing
// about noise, so it is not a trend.
const two = [at(5 * DAY, 130), at(0, 120)];
expect(trendSlope(two, 7, NOW)).toBeNull();
expect(trendSlope(two, 30, NOW)).toBeNull();
});
it("withholds the slope when three days cover too little of the window", () => {
// Three consecutive days: a span of 2 against the 4 a 7-day window
// needs, and the 15 a 30-day window needs.
const consecutive = [at(2 * DAY, 128), at(1 * DAY, 126), at(0, 124)];
expect(trendSlope(consecutive, 7, NOW)).toBeNull();
expect(trendSlope(consecutive, 30, NOW)).toBeNull();
});
it("fits sparse 30-day data that spans the window, not the empty week", () => {
// Three readings 25, 14 and 9 days ago: nothing in the 7-day window,
// 16 days of span in the 30-day one.
const sparse = [at(25 * DAY, 130), at(14 * DAY, 127), at(9 * DAY, 125)];
expect(trendSlope(sparse, 7, NOW)).toBeNull();
const slope30 = trendSlope(sparse, 30, NOW);
expect(slope30).not.toBeNull();
expect(slope30!.direction).toBe("down");
// About −0.31 mmHg per day: a plausible drift, not thousands.
expect(slope30!.slope).toBeGreaterThan(-0.5);
expect(slope30!.slope).toBeLessThan(-0.2);
});
it("fits normal daily data", () => {
const daily = [0, 1, 2, 3, 4, 5, 6].map((d) => at((6 - d) * DAY, 80 + d));
const slope7 = trendSlope(daily, 7, NOW);
expect(slope7).toEqual({ slope: 1, direction: "up", confidence: 1 });
});
it("keeps a same-session cluster from dominating a window that earned a trend", () => {
// A week of single daily readings, rising one mmHg a day, plus a
// morning session of three readings minutes apart that wobble by 12.
const week = [0, 1, 2, 3, 4, 5].map((d) => at((7 - d) * DAY, 120 + d));
const sitting = [
at(10 * MINUTE, 130),
at(8 * MINUTE, 124),
at(6 * MINUTE, 118),
];
const slope7 = trendSlope([...week, ...sitting], 7, NOW);
expect(slope7).not.toBeNull();
expect(Math.abs(slope7!.slope)).toBeLessThan(2);
});
it("leaves the dashboard tile without a 7-day delta for one sitting", () => {
// The tile's delta is `summaryToTrend7Delta(summary)`: slope7 × 7,
// else slope30 × 7. Before the minimum-evidence rule this summary
// yielded about −15,000 mmHg; now neither window carries a slope.
const sitting = [
{ date: new Date(Date.now() - 10 * MINUTE), value: 130 },
{ date: new Date(Date.now() - 8 * MINUTE), value: 124 },
{ date: new Date(Date.now() - 6 * MINUTE), value: 118 },
];
const summary = summarize(sitting);
expect(summary.avg7).toBe(124);
expect(summary.slope7).toBeNull();
expect(summary.slope30).toBeNull();
expect(summaryToTrend7Delta(summary)).toBeNull();
});
it("falls back to a sane 30-day delta when only the week is one sitting", () => {
// Earlier readings across the month earn the 30-day window a trend;
// the week's sitting does not earn the 7-day window one. The tile
// shows the 30-day slope projected onto 7 days: a few mmHg.
const month = [28, 21, 14, 9].map((d, i) => ({
date: new Date(Date.now() - d * DAY),
value: 132 - i,
}));
const sitting = [
{ date: new Date(Date.now() - 10 * MINUTE), value: 130 },
{ date: new Date(Date.now() - 8 * MINUTE), value: 124 },
{ date: new Date(Date.now() - 6 * MINUTE), value: 118 },
];
const summary = summarize([...month, ...sitting]);
expect(summary.slope7).toBeNull();
expect(summary.slope30).not.toBeNull();
const delta = summaryToTrend7Delta(summary);
expect(delta).not.toBeNull();
expect(Math.abs(delta!)).toBeLessThan(10);
});
});
describe("detectAnomalies", () => {
it("returns empty for insufficient data", () => {
expect(detectAnomalies(makePoints([70, 71]))).toEqual([]);
@@ -2,7 +2,11 @@ import { describe, it, expect } from "vitest";
import {
computeWindowConfidence,
isTrendWindowSufficient,
MIN_READINGS_FOR_CONFIDENCE,
MIN_TREND_DAYS,
minTrendSpanDays,
trendEvidenceOf,
} from "../window-confidence";
const NOW = new Date("2026-06-14T12:00:00.000Z");
@@ -72,3 +76,41 @@ describe("computeWindowConfidence", () => {
expect(c.effectiveSpanDays).toBe(1);
});
});
describe("trend evidence", () => {
const MINUTE = 60 * 1000;
it("counts UTC days and the span between the first and the last", () => {
expect(trendEvidenceOf([])).toEqual({ days: 0, spanDays: 0 });
// Three readings minutes apart: one day, no span.
const t = Date.UTC(2026, 5, 14, 8, 0, 0);
expect(trendEvidenceOf([t, t + 2 * MINUTE, t + 4 * MINUTE])).toEqual({
days: 1,
spanDays: 0,
});
// Either side of UTC midnight: two days, one apart.
const midnight = Date.UTC(2026, 5, 14);
expect(trendEvidenceOf([midnight - MINUTE, midnight + MINUTE])).toEqual({
days: 2,
spanDays: 1,
});
expect(
trendEvidenceOf(
[daysAgo(9), daysAgo(5), daysAgo(5), daysAgo(0)].map((d) =>
d.getTime(),
),
),
).toEqual({ days: 3, spanDays: 9 });
});
it("asks for three days spanning half the window", () => {
expect(MIN_TREND_DAYS).toBe(3);
expect(minTrendSpanDays(7)).toBe(4);
expect(minTrendSpanDays(30)).toBe(15);
expect(isTrendWindowSufficient({ days: 3, spanDays: 4 }, 7)).toBe(true);
expect(isTrendWindowSufficient({ days: 2, spanDays: 6 }, 7)).toBe(false);
expect(isTrendWindowSufficient({ days: 3, spanDays: 3 }, 7)).toBe(false);
expect(isTrendWindowSufficient({ days: 3, spanDays: 15 }, 30)).toBe(true);
expect(isTrendWindowSufficient({ days: 20, spanDays: 14 }, 30)).toBe(false);
});
});
+13 -30
View File
@@ -72,6 +72,11 @@ import {
windowMeanSql,
} from "@/lib/measurements/day-mean";
import { wireSummary, type DataSummary } from "@/lib/analytics/trends";
import {
TREND_EVIDENCE_SQL,
liveTrend,
type LiveTrendColumns,
} from "@/lib/analytics/trend-evidence-sql";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { annotate } from "@/lib/logging/context";
import { caches, type ServerCache } from "@/lib/cache/server-cache";
@@ -84,7 +89,7 @@ import {
import {
aggregateBuckets,
collapseRollupRowsBySource,
composeWindowedRegression,
composeWindowedTrend,
hasPendingAccumulatorBackfill,
loadUserSourcePriority,
type AccumulatorBucketRow,
@@ -219,28 +224,7 @@ interface NarrowAggregateRow {
* live REGR_* regression (no rollup accumulators to compose from yet), so it
* carries the slope / r² columns the warm `NarrowAggregateRow` dropped.
*/
interface WindowedAggregateRow extends NarrowAggregateRow {
slope7: number | null;
r2_7: number | null;
slope30: number | null;
r2_30: number | null;
}
/** Same threshold/rounding contract as `trendSlope()` in `trends.ts`. */
function buildSlope(
slope: number | null,
rSquared: number | null,
): DataSummary["slope7"] {
if (slope === null) return null;
const threshold = 0.01;
const direction: "up" | "down" | "stable" =
Math.abs(slope) < threshold ? "stable" : slope > 0 ? "up" : "down";
return {
slope: Math.round(slope * 1000) / 1000,
direction,
confidence: rSquared === null ? 0 : Math.round(rSquared * 100) / 100,
};
}
interface WindowedAggregateRow extends NarrowAggregateRow, LiveTrendColumns {}
function round2(value: number | null): number | null {
if (value === null || !Number.isFinite(value)) return null;
@@ -834,8 +818,6 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
) {
regressionPendingBackfill = true;
}
const reg7 = composeWindowedRegression(accBuckets, since7);
const reg30 = composeWindowedRegression(accBuckets, since30);
// v1.37.19 — 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
@@ -862,8 +844,8 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
median: round2(narrow?.median ?? null),
avg7: round2(narrow?.avg7 ?? null),
avg30: round2(narrow?.avg30 ?? null),
slope7: buildSlope(reg7.slope, reg7.r2),
slope30: buildSlope(reg30.slope, reg30.r2),
slope7: composeWindowedTrend(accBuckets, since7, 7),
slope30: composeWindowedTrend(accBuckets, since30, 30),
avg30LastMonth: round2(narrow?.avg30_last_month ?? null),
avg30LastYear: null,
};
@@ -1047,7 +1029,8 @@ async function computeFromLiveAggregate(
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
) FILTER (
WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days')
)::double precision AS r2_30
)::double precision AS r2_30,
${TREND_EVIDENCE_SQL}
FROM ${dayWeightedRows("cm", SESSION_DAY_FRAME)} m
GROUP BY m."type"
`,
@@ -1113,8 +1096,8 @@ async function computeFromLiveAggregate(
median: round2(win?.median ?? null),
avg7: round2(win?.avg7 ?? null),
avg30: round2(win?.avg30 ?? null),
slope7: buildSlope(win?.slope7 ?? null, win?.r2_7 ?? null),
slope30: buildSlope(win?.slope30 ?? null, win?.r2_30 ?? null),
slope7: liveTrend(win, 7),
slope30: liveTrend(win, 30),
avg30LastMonth: round2(win?.avg30_last_month ?? null),
avg30LastYear: null,
};
+8
View File
@@ -24,6 +24,14 @@
* a 7-day window for label consistency. Returning `null` is reserved
* for the truly insufficient case where neither slope is available.
*
* Neither slope reaches this function unless its window earned it: a
* window whose readings fall on fewer than three days, or cover less than
* half the window, publishes no slope (`MIN_TREND_DAYS` in
* `window-confidence.ts`). Before that rule two blood-pressure readings a
* few minutes apart, the only ones of the week, fitted a slope of thousands
* of mmHg per day and the tile printed it as the 7-day trend. The 30-day
* fallback obeys the same rule over its own window.
*
* Pure & deterministic — pinned by unit test.
*/
import type { TrendSlope } from "@/lib/analytics/trends";
+64
View File
@@ -0,0 +1,64 @@
/**
* The trend evidence of the live-SQL slope windows, as SELECT columns.
*
* The cold-fallback summaries (`summaries-slice.ts`, the comprehensive
* aggregator) fit `slope7` / `slope30` with `REGR_SLOPE` over the canonical
* readings. Whether a window may publish that slope is decided in JS by
* `toTrendSlope` from the window's distinct reading days and their span (see
* `MIN_TREND_DAYS` in `window-confidence.ts`); these columns hand it exactly
* that, over the same rows and the same UTC-midnight window bounds the
* `REGR_*` FILTER clauses use, so the live path withholds a slope on the
* same evidence the warm rollup path does (`composeWindowedTrend`).
*
* `measured_at` is a naive UTC timestamp, so `::date` is its UTC calendar
* day, the day the rollup tier buckets by; `date - date` is whole days. The
* text is a file literal with no input spliced in. It expects the row alias
* `m` and a `GROUP BY` per type.
*/
import { toTrendSlope, type TrendSlope } from "@/lib/analytics/trends";
function windowFilter(days: 7 | 30): string {
return `m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '${days} days')`;
}
function evidenceColumns(days: 7 | 30): string {
const filter = windowFilter(days);
return `COUNT(DISTINCT m."measured_at"::date) FILTER (WHERE ${filter})::int AS days${days},
(MAX(m."measured_at"::date) FILTER (WHERE ${filter})
- MIN(m."measured_at"::date) FILTER (WHERE ${filter}))::int AS span${days}`;
}
/** `days7, span7, days30, span30` for a per-type live aggregate. */
export const TREND_EVIDENCE_SQL = `${evidenceColumns(7)},
${evidenceColumns(30)}`;
/** The slope, r² and evidence columns a live per-type aggregate returns. */
export interface LiveTrendColumns {
slope7: number | null;
r2_7: number | null;
slope30: number | null;
r2_30: number | null;
/** UTC days holding a reading in each slope window, and their span. */
days7: number | null;
span7: number | null;
days30: number | null;
span30: number | null;
}
/** The published trend of one live window, gated on its evidence columns. */
export function liveTrend(
row: LiveTrendColumns | undefined,
windowDays: 7 | 30,
): TrendSlope | null {
if (!row) return null;
const [slope, r2, days, span] =
windowDays === 7
? [row.slope7, row.r2_7, row.days7, row.span7]
: [row.slope30, row.r2_30, row.days30, row.span30];
return toTrendSlope(
slope,
r2,
{ days: days ?? 0, spanDays: span ?? 0 },
windowDays,
);
}
+42 -14
View File
@@ -5,6 +5,11 @@
import { READING_SPREAD, standardDeviation } from "@/lib/stats/spread";
import { linearFit } from "@/lib/stats/linear-fit";
import {
isTrendWindowSufficient,
trendEvidenceOf,
type TrendEvidence,
} from "@/lib/analytics/window-confidence";
export interface DataPoint {
date: Date;
@@ -19,6 +24,35 @@ export interface TrendSlope {
confidence: number; // R² value 0-1
}
/**
* The one place a fitted slope becomes a published trend, shared by the JS
* fit below and the rollup / live-SQL summaries (which fit in Postgres or
* compose the fit from stored accumulators). `null` when the window's
* readings do not cover enough days for a trend (`isTrendWindowSufficient`),
* so slope, direction and confidence are present or absent together on
* every path.
*/
export function toTrendSlope(
slope: number | null,
rSquared: number | null,
evidence: TrendEvidence,
windowDays: number,
): TrendSlope | null {
if (slope === null || !Number.isFinite(slope)) return null;
if (!isTrendWindowSufficient(evidence, windowDays)) return null;
const threshold = 0.01; // units per day threshold for "stable"
const direction: "up" | "down" | "stable" =
Math.abs(slope) < threshold ? "stable" : slope > 0 ? "up" : "down";
return {
slope: Math.round(slope * 1000) / 1000,
direction,
// No variance in y: a flat line explains nothing, as it always read.
confidence: rSquared === null ? 0 : Math.round(rSquared * 100) / 100,
};
}
/**
* Linear regression slope over the last N days.
* Uses least squares fit.
@@ -28,6 +62,10 @@ export interface TrendSlope {
* same way `summarize().avg7/avg30` returns `null`. The v3 audit caught the
* mismatch where `trendSlope` reported a "trend" from old data while the
* dashboard tile correctly hid the average.
*
* `null` too when the in-window readings fall on fewer than three days or
* span less than half the window: a fit across readings minutes apart turns
* a few mmHg into thousands per day (see `MIN_TREND_DAYS`).
*/
export function trendSlope(
data: DataPoint[],
@@ -39,9 +77,11 @@ export function trendSlope(
const sorted = [...data].sort((a, b) => a.date.getTime() - b.date.getTime());
const cutoff = now - windowDays * 24 * 60 * 60 * 1000;
const window = sorted.filter((p) => p.date.getTime() >= cutoff);
if (window.length < 2) return null;
const evidence = trendEvidenceOf(window.map((p) => p.date.getTime()));
if (!isTrendWindowSufficient(evidence, windowDays)) return null;
// Convert dates to days from start
const startTime = window[0].date.getTime();
const points = window.map((p) => ({
@@ -54,19 +94,7 @@ export function trendSlope(
points.map((p) => p.y),
);
if (!fit) return null;
const { slope } = fit;
// No variance in y: a flat line explains nothing, as it always read.
const rSquared = fit.r2 ?? 0;
const threshold = 0.01; // units per day threshold for "stable"
const direction: "up" | "down" | "stable" =
Math.abs(slope) < threshold ? "stable" : slope > 0 ? "up" : "down";
return {
slope: Math.round(slope * 1000) / 1000,
direction,
confidence: Math.round(rSquared * 100) / 100,
};
return toTrendSlope(fit.slope, fit.r2, evidence, windowDays);
}
// ── Anomaly Detection ────────────────────────────────────
+79
View File
@@ -111,3 +111,82 @@ export function computeWindowConfidence(input: {
return { effectiveSpanDays, sufficient };
}
/**
* Minimum evidence before a trailing window may report a TREND.
*
* Every windowed trend (`slope7` / `slope30` on a summary, the per-bucket
* slope of a coarse rollup bucket) is a least-squares slope in units per
* DAY. When the window's readings sit close together in time — two blood
* pressure readings a few minutes apart being the only ones of the week —
* the x-span is a few minutes, the slope per day explodes, and the 7-day
* projection the dashboard tile prints reads as thousands of mmHg. A reading
* count cannot catch this (`isWindowSufficient` admits five readings taken
* in one sitting), so the trend gate is about time, not count:
*
* 1. the readings fall on at least `MIN_TREND_DAYS` distinct days, and
* 2. the first and last of those days lie at least half the window apart
* (`minTrendSpanDays`): 4 days for a 7-day window, 15 for a 30-day one.
*
* Why these two. A least-squares slope is the (Δx)²-weighted average of the
* slopes between every pair of readings, so once readings days apart exist,
* a same-session cluster carries almost no weight; the span floor guarantees
* such pairs exist and bounds how far a 7-day projection reaches beyond what
* was observed. Three days is the least that lets the fit tell a trend from
* a two-cluster difference: through two days a line fits perfectly and its
* R² says nothing. The same days-plus-span shape gates the medication
* segmented-effect fit (`SEGMENTED_MIN_DAYS_PER_SIDE` / `_SPAN_DAYS`).
*
* The fit itself stays over the individual readings, as it was: the window
* means beside it (`avg7` / `avg30`) average readings too, and the rollup
* accumulators the warm path composes from are per reading. The gate
* decides only whether the window has earned a slope at all.
*
* Days are UTC calendar days: the rollup tier and the live SQL bucket by UTC
* day, and the gate must give the same answer on the JS, rollup and live
* paths, or a coverage transition would flip a tile's trend on and off.
*/
export const MIN_TREND_DAYS = 3;
/** How many days the trend evidence in a window covers. */
export interface TrendEvidence {
/** Distinct UTC calendar days in the window that hold a reading. */
days: number;
/** Whole UTC days between the first and the last of those days. */
spanDays: number;
}
/** The least first-to-last span a window's readings must cover for a trend. */
export function minTrendSpanDays(windowDays: number): number {
return Math.ceil(windowDays / 2);
}
/** `true` once a window's readings may carry a trend (see `MIN_TREND_DAYS`). */
export function isTrendWindowSufficient(
evidence: TrendEvidence,
windowDays: number,
): boolean {
return (
evidence.days >= MIN_TREND_DAYS &&
evidence.spanDays >= minTrendSpanDays(windowDays)
);
}
/**
* The trend evidence of a set of instants (epoch milliseconds): how many
* distinct UTC days they fall on and how many days lie between the first and
* the last of them.
*/
export function trendEvidenceOf(instantsMs: Iterable<number>): TrendEvidence {
let first = Infinity;
let last = -Infinity;
const days = new Set<number>();
for (const ms of instantsMs) {
const day = Math.floor(ms / DAY_MS);
days.add(day);
if (day < first) first = day;
if (day > last) last = day;
}
if (days.size === 0) return { days: 0, spanDays: 0 };
return { days: days.size, spanDays: last - first };
}
@@ -158,28 +158,45 @@ describe("buildComprehensiveAggregate", () => {
// `collapseRollupRowsBySource` can resolve dual-source days; with
// a single source per day every bucket passes through unchanged.
//
// v1.20.0 — buckets are placed on the two most-recent UTC days so
// they fall inside the 7-day slope window, and carry the regression
// v1.20.0 — buckets are placed on recent UTC days so they fall
// inside the 7-day slope window, and carry the regression
// accumulators the windowed slope / r² compose from. Each bucket
// models its `count` readings as all landing at the bucket's day
// midnight (x = bucketStart epoch-day) with value = mean, which is a
// valid, self-consistent accumulator set.
// valid, self-consistent accumulator set. The 81-mean readings are
// split over two days (5 and 3 days ago) so the window holds three
// days spanning five: enough evidence for a trend.
const dayB = startOfUtcDay(now);
const dayA = startOfUtcDay(new Date(now.getTime() - 86_400_000));
const dayA = startOfUtcDay(new Date(now.getTime() - 5 * 86_400_000));
const dayA2 = startOfUtcDay(new Date(now.getTime() - 3 * 86_400_000));
const xA = dayA.getTime() / 86_400_000;
const xA2 = dayA2.getTime() / 86_400_000;
const xB = dayB.getTime() / 86_400_000;
const bucketA = {
type: "WEIGHT",
source: "APPLE_HEALTH",
bucketStart: dayA,
count: 20,
count: 10,
mean: 81.0,
minValue: 79.2,
maxValue: 82.5,
sumX: 20 * xA,
sumXy: 20 * xA * 81.0,
sumXx: 20 * xA * xA,
sumYy: 20 * 81.0 * 81.0,
sumX: 10 * xA,
sumXy: 10 * xA * 81.0,
sumXx: 10 * xA * xA,
sumYy: 10 * 81.0 * 81.0,
};
const bucketA2 = {
type: "WEIGHT",
source: "APPLE_HEALTH",
bucketStart: dayA2,
count: 10,
mean: 81.0,
minValue: 79.2,
maxValue: 82.5,
sumX: 10 * xA2,
sumXy: 10 * xA2 * 81.0,
sumXx: 10 * xA2 * xA2,
sumYy: 10 * 81.0 * 81.0,
};
const bucketB = {
type: "WEIGHT",
@@ -194,22 +211,24 @@ describe("buildComprehensiveAggregate", () => {
sumXx: 22 * xB * xB,
sumYy: 22 * 83.0 * 83.0,
};
ROLLUP_FIND_MANY.mockResolvedValueOnce([bucketA, bucketB]);
ROLLUP_FIND_MANY.mockResolvedValueOnce([bucketA, bucketA2, bucketB]);
// Expected slope7 — composed from the same accumulators the reader
// sees (the buildSlope contract rounds slope to 3 dp, confidence to
// 2 dp). Computed here from composeWindowedRegression so the test
// tracks the closed form rather than a hand-typed magic number.
const accBuckets: AccumulatorBucketRow[] = [bucketA, bucketB].map(
(b) => ({
bucketStart: b.bucketStart,
count: b.count,
mean: b.mean,
sumX: b.sumX,
sumXy: b.sumXy,
sumXx: b.sumXx,
sumYy: b.sumYy,
}),
);
const accBuckets: AccumulatorBucketRow[] = [
bucketA,
bucketA2,
bucketB,
].map((b) => ({
bucketStart: b.bucketStart,
count: b.count,
mean: b.mean,
sumX: b.sumX,
sumXy: b.sumXy,
sumXx: b.sumXx,
sumYy: b.sumYy,
}));
const since7 = startOfUtcDay(new Date(Date.now() - 7 * 86_400_000));
const expectedReg7 = composeWindowedRegression(accBuckets, since7);
@@ -235,9 +254,9 @@ describe("buildComprehensiveAggregate", () => {
expect(weight.avg30).toBe(82.1);
expect(weight.avg30LastMonth).toBe(83.0);
expect(weight.anomalyCount).toBe(3);
// v1.20.0 — slope7 composes from the accumulator buckets. The two
// recent buckets (means 81 → 83 one day apart) yield a positive,
// perfect-fit slope; assert against the closed form the reader uses.
// v1.20.0 — slope7 composes from the accumulator buckets. The
// recent buckets (means 81, 81 → 83 over five days) yield a positive
// slope; assert against the closed form the reader uses.
expect(expectedReg7.slope).not.toBeNull();
expect(weight.slope7).toEqual({
slope: Math.round((expectedReg7.slope as number) * 1000) / 1000,
@@ -249,6 +268,7 @@ describe("buildComprehensiveAggregate", () => {
});
expect(result.dailyByType.WEIGHT).toEqual([
{ day: dayA.toISOString().slice(0, 10), value: 81 },
{ day: dayA2.toISOString().slice(0, 10), value: 81 },
{ day: dayB.toISOString().slice(0, 10), value: 83 },
]);
@@ -327,6 +347,11 @@ describe("buildComprehensiveAggregate", () => {
r2_7: 0.65,
slope30: -0.005,
r2_30: 0.42,
// Readings on enough days, spread far enough, for both windows.
days7: 6,
span7: 6,
days30: 20,
span30: 29,
},
])
.mockResolvedValueOnce([
@@ -367,6 +392,53 @@ describe("buildComprehensiveAggregate", () => {
// type, they feed `dailyByType` so the v1.4.35 shape is stable.
expect(result.dailyByType.WEIGHT).toBeUndefined();
});
it("withholds a slope whose window readings cover too few days", async () => {
const now = new Date();
RAW.mockResolvedValueOnce([
{ type: "BLOOD_PRESSURE_SYS", has_buckets: false },
]).mockResolvedValueOnce([{ first_at: now }]);
const queries: string[] = [];
UNSAFE.mockImplementationOnce((sql: string) => {
queries.push(sql);
return Promise.resolve([
{
type: "BLOOD_PRESSURE_SYS",
count: BigInt(3),
min_value: 118,
max_value: 130,
mean_value: 124,
stddev_value: 6,
anomaly_count: BigInt(0),
avg7: 124,
avg30: 124,
avg30_last_month: null,
// REGR_SLOPE over three readings minutes apart, one day.
slope7: -2160,
r2_7: 1,
slope30: -2160,
r2_30: 1,
days7: 1,
span7: 0,
days30: 1,
span30: 0,
},
]);
})
.mockResolvedValueOnce([
{ type: "BLOOD_PRESSURE_SYS", value: 124, measured_at: now },
])
.mockResolvedValueOnce([]);
const result = await buildComprehensiveAggregate("user-cluster");
const sys = result.summaries.BLOOD_PRESSURE_SYS;
expect(sys.avg7).toBe(124);
expect(sys.slope7).toBeNull();
expect(sys.slope30).toBeNull();
// The evidence is read by the same aggregate that fits the slope.
expect(queries[0]).toContain("AS days7");
expect(queries[0]).toContain("AS span30");
});
});
it("threads sys/dia raw rows through bpRawRows so 5-min pairing survives", async () => {
+13 -29
View File
@@ -99,6 +99,11 @@
*/
import { prisma } from "@/lib/db";
import type { DataSummary } from "@/lib/analytics/trends";
import {
TREND_EVIDENCE_SQL,
liveTrend,
type LiveTrendColumns,
} from "@/lib/analytics/trend-evidence-sql";
import { annotate } from "@/lib/logging/context";
import type {
MeasurementSource,
@@ -108,7 +113,7 @@ import { ensureUserRollupsFresh } from "@/lib/rollups/measurement-rollups";
import {
aggregateBuckets,
collapseRollupRowsBySource,
composeWindowedRegression,
composeWindowedTrend,
hasPendingAccumulatorBackfill,
loadUserSourcePriority,
type AccumulatorBucketRow,
@@ -162,7 +167,7 @@ const AVG30_LAST_MONTH_SQL = windowedMean(
* rollup table is empty (the populator hasn't caught up yet) so the
* response shape is correct on the very first request.
*/
interface HeavyAggregateRow {
interface HeavyAggregateRow extends LiveTrendColumns {
type: string;
count: bigint;
min_value: number | null;
@@ -173,10 +178,6 @@ interface HeavyAggregateRow {
avg7: number | null;
avg30: number | null;
avg30_last_month: number | null;
slope7: number | null;
r2_7: number | null;
slope30: number | null;
r2_30: number | null;
}
/**
@@ -238,22 +239,6 @@ export interface ComprehensiveAggregate {
totalMeasurements: number;
}
/** Same threshold/rounding contract as `trendSlope()` in `trends.ts`. */
function buildSlope(
slope: number | null,
rSquared: number | null,
): DataSummary["slope7"] {
if (slope === null) return null;
const threshold = 0.01;
const direction: "up" | "down" | "stable" =
Math.abs(slope) < threshold ? "stable" : slope > 0 ? "up" : "down";
return {
slope: Math.round(slope * 1000) / 1000,
direction,
confidence: rSquared === null ? 0 : Math.round(rSquared * 100) / 100,
};
}
function round2(value: number | null): number | null {
if (value === null || !Number.isFinite(value)) return null;
return Math.round(value * 100) / 100;
@@ -576,8 +561,6 @@ async function buildFromRollups(
) {
regressionPendingBackfill = true;
}
const reg7 = composeWindowedRegression(accBuckets, since7);
const reg30 = composeWindowedRegression(accBuckets, since30);
summaries[type] = {
count: composed.count,
latest: latest?.value ?? null,
@@ -591,8 +574,8 @@ async function buildFromRollups(
median: null,
avg7: round2(narrow?.avg7 ?? null),
avg30: round2(narrow?.avg30 ?? null),
slope7: buildSlope(reg7.slope, reg7.r2),
slope30: buildSlope(reg30.slope, reg30.r2),
slope7: composeWindowedTrend(accBuckets, since7, 7),
slope30: composeWindowedTrend(accBuckets, since30, 30),
anomalyCount: Number(narrow?.anomaly_count ?? 0),
avg30LastMonth: round2(narrow?.avg30_last_month ?? null),
// Legacy semantics: the 90-day window guarantees no rows from a
@@ -734,7 +717,8 @@ async function buildFromLiveAggregate(
EXTRACT(EPOCH FROM m."measured_at") / 86400.0
) FILTER (
WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days')
)::double precision AS r2_30
)::double precision AS r2_30,
${TREND_EVIDENCE_SQL}
FROM ${dayWeightedRows("cm", SESSION_DAY_FRAME)} m
JOIN window_stats ws ON ws."type" = m."type"
GROUP BY m."type", ws.stddev_value
@@ -794,8 +778,8 @@ async function buildFromLiveAggregate(
median: null,
avg7: round2(row.avg7),
avg30: round2(row.avg30),
slope7: buildSlope(row.slope7, row.r2_7),
slope30: buildSlope(row.slope30, row.r2_30),
slope7: liveTrend(row, 7),
slope30: liveTrend(row, 30),
anomalyCount: Number(row.anomaly_count),
avg30LastMonth: round2(row.avg30_last_month),
avg30LastYear: null,
+1 -1
View File
@@ -47,7 +47,7 @@ const trendSlope = z
.meta({
id: "TrendSlope",
description:
"A linear trend over a trailing window. The window is anchored on NOW, not on the newest reading, so a series that stopped weeks ago reports `null` for the slope instead of describing a trend that has not been observed since.",
"A linear trend over a trailing window. The window is anchored on NOW, not on the newest reading, so a series that stopped weeks ago reports `null` for the slope instead of describing a trend that has not been observed since. It is also `null` when the window's readings fall on fewer than 3 calendar days (UTC) or when their first and last day lie less than half the window apart (4 days for the 7-day window, 15 for the 30-day one): readings minutes apart cannot carry a slope per day.",
});
const dataSummary = z
+1 -1
View File
@@ -1941,7 +1941,7 @@ export const dataSummary = z
slope30: trendSlope
.nullable()
.describe(
"Regression over the trailing window, anchored on NOW rather than on the newest reading — a series that stopped weeks ago reports null rather than a stale slope.",
"Regression over the trailing window, anchored on NOW rather than on the newest reading — a series that stopped weeks ago reports null rather than a stale slope. Null as well (for `slope7` too) when the window's readings fall on fewer than 3 calendar days (UTC) or span less than half the window.",
),
avg30LastMonth: z
.number()
@@ -141,6 +141,8 @@ function folded(
sum_xy: withAccumulators ? sumXy : null,
sum_xx: withAccumulators ? sumXx : null,
sum_yy: withAccumulators ? sumYy : null,
first_day: new Date(`${[...byDay.keys()].sort()[0]}T00:00:00.000Z`),
last_day: new Date(`${[...byDay.keys()].sort().at(-1)}T00:00:00.000Z`),
computed_at: new Date("2026-06-01T00:00:00.000Z"),
};
}
@@ -252,8 +254,29 @@ describe("readBestGranularityRollups", () => {
});
it("composes a coarse bucket's mean, spread and slope from its days", async () => {
// Three days of one reading each: 70, 72, 74 → mean 72, slope 2 per
// day, population sd sqrt(8/3), r² 1.
// Three days of one reading each, eight days apart: 70, 86, 102 → mean
// 86, slope 2 per day, population sd sqrt(512/3), r² 1. The days span
// 16 of the month's 30, enough of the bucket to carry a trend.
queryRaw.mockResolvedValueOnce([
folded("2025-11-01T00:00:00.000Z", [
["2025-11-03", 70],
["2025-11-11", 86],
["2025-11-19", 102],
]),
]);
const result = await readBestGranularityRollups("user", "WEIGHT", 365);
const month = result?.rows[0];
expect(month?.count).toBe(3);
expect(month?.mean).toBeCloseTo(86, 9);
expect(month?.sumValue).toBe(258);
expect(month?.slope).toBeCloseTo(2, 6);
expect(month?.r2).toBeCloseTo(1, 6);
expect(month?.sd).toBeCloseTo(Math.sqrt(512 / 3), 6);
});
it("reports no slope for a bucket whose days cover too little of it", async () => {
// Three consecutive days of a 30-day month: the spread composes, but a
// slope per day over two days of a month is no monthly trend.
queryRaw.mockResolvedValueOnce([
folded("2025-11-01T00:00:00.000Z", [
["2025-11-03", 70],
@@ -263,12 +286,47 @@ describe("readBestGranularityRollups", () => {
]);
const result = await readBestGranularityRollups("user", "WEIGHT", 365);
const month = result?.rows[0];
expect(month?.count).toBe(3);
expect(month?.mean).toBeCloseTo(72, 9);
expect(month?.sumValue).toBe(216);
expect(month?.slope).toBeCloseTo(2, 6);
expect(month?.r2).toBeCloseTo(1, 6);
expect(month?.sd).toBeCloseTo(Math.sqrt(8 / 3), 6);
expect(month?.slope).toBeNull();
expect(month?.r2).toBeNull();
});
it("reports no slope for a year whose readings are one sitting", async () => {
// Three readings minutes apart on one day: the slope per day composed
// from them is thousands of units, and the year bucket withholds it.
const day = Date.parse("2024-06-10T08:00:00.000Z") / DAY_MS - ORIGIN_DAYS;
const minute = 1 / (24 * 60);
const xs = [day, day + 2 * minute, day + 4 * minute];
const ys = [130, 124, 118];
queryRaw.mockResolvedValueOnce([
{
bucket_start: new Date("2024-01-01T00:00:00.000Z"),
count: 3,
days: 1,
sum_day_mean: 124,
sum_y: 372,
min_value: 118,
max_value: 130,
sum_x: xs.reduce((a, x) => a + x, 0),
sum_xy: xs.reduce((a, x, i) => a + x * ys[i], 0),
sum_xx: xs.reduce((a, x) => a + x * x, 0),
sum_yy: ys.reduce((a, y) => a + y * y, 0),
first_day: new Date("2024-06-10T00:00:00.000Z"),
last_day: new Date("2024-06-10T00:00:00.000Z"),
computed_at: new Date("2026-06-01T00:00:00.000Z"),
},
]);
const result = await readBestGranularityRollups(
"user",
"BLOOD_PRESSURE_SYS",
1100,
);
const year = result?.rows[0];
expect(year?.count).toBe(3);
expect(year?.mean).toBeCloseTo(124, 9);
expect(year?.slope).toBeNull();
expect(year?.r2).toBeNull();
});
it("reports no spread or slope for a bucket with a day lacking accumulators", async () => {
+56 -5
View File
@@ -39,6 +39,11 @@ import {
parseSourcePriority,
} from "@/lib/validations/source-priority";
import { fitFromSums } from "@/lib/stats/linear-fit";
import {
isTrendWindowSufficient,
trendEvidenceOf,
} from "@/lib/analytics/window-confidence";
import { toTrendSlope, type TrendSlope } from "@/lib/analytics/trends";
/**
* v1.11.1 — load a user's source-priority blob for the rollup collapse.
@@ -344,6 +349,28 @@ export function composeWindowedRegression(
return composeRegression(inWindow);
}
/**
* The published trend over the DAY buckets on or after `since`: the
* regression `composeWindowedRegression` composes, withheld by `toTrendSlope`
* when the window's days with a reading are too few or span too little of
* `windowDays` (`isTrendWindowSufficient`). Days are the buckets' UTC days,
* the same days the live path counts with `TREND_EVIDENCE_SQL`.
*/
export function composeWindowedTrend(
rows: ReadonlyArray<AccumulatorBucketRow>,
since: Date,
windowDays: number,
): TrendSlope | null {
const cutoff = since.getTime();
const evidence = trendEvidenceOf(
rows
.filter((r) => r.count > 0 && r.bucketStart.getTime() >= cutoff)
.map((r) => r.bucketStart.getTime()),
);
const { slope, r2 } = composeWindowedRegression(rows, since);
return toTrendSlope(slope, r2, evidence, windowDays);
}
/**
* v1.20.0 — true iff any in-window DAY bucket carries a NULL
* regression accumulator (a row that predates migration 0190, or whose
@@ -516,6 +543,9 @@ interface FoldedBucketRow {
sum_xy: number | null;
sum_xx: number | null;
sum_yy: number | null;
/** The bucket's first and last canonical day: the span of its trend evidence. */
first_day: Date;
last_day: Date;
computed_at: Date;
}
@@ -530,10 +560,14 @@ const COARSE_TRUNC_UNIT: Record<Exclude<RollupGranularity, "DAY">, string> = {
* Compose one coarse bucket from the summed stats of its canonical days.
* `count`, `min`, `max`, `sum` and the count-weighted `mean` are exact;
* `sd`, `slope` and `r2` come from the summed regression accumulators
* (`composeRegression`) and are `null` when a day lacks them.
* (`composeRegression`) and are `null` when a day lacks them. The bucket is
* the slope's window, so `slope` and `r2` are also `null` when its days do
* not cover enough of it for a trend (`isTrendWindowSufficient`): a year
* whose readings are one afternoon's has no slope per day worth reporting.
*/
function composeFoldedBucket(
row: FoldedBucketRow,
granularity: Exclude<RollupGranularity, "DAY">,
weighting: WindowWeighting = "count",
): CanonicalRollupBucket {
const count = Number(row.count);
@@ -558,8 +592,23 @@ function composeFoldedBucket(
{ count, mean: sampleMean, sumValue: sumY, sumX, sumXy, sumXx, sumYy },
])
: { slope: null, r2: null, sdPop: null };
const bucketStart = new Date(row.bucket_start);
const bucketDays = Math.round(
(utcBucketEnd(bucketStart, granularity).getTime() - bucketStart.getTime()) /
DAY_MS,
);
const hasTrend = isTrendWindowSufficient(
{
days: Number(row.days),
spanDays: Math.round(
(new Date(row.last_day).getTime() - new Date(row.first_day).getTime()) /
DAY_MS,
),
},
bucketDays,
);
return {
bucketStart: new Date(row.bucket_start),
bucketStart,
count,
days: Number(row.days),
dayMean,
@@ -569,8 +618,8 @@ function composeFoldedBucket(
sumValue: sumY,
// A single reading has no spread; STDDEV_POP of one value is 0.
sd: count === 1 && accumulatorsComplete ? 0 : regression.sdPop,
slope: regression.slope,
r2: regression.r2,
slope: hasTrend ? regression.slope : null,
r2: hasTrend ? regression.r2 : null,
sumX,
sumXy,
sumXx,
@@ -706,6 +755,8 @@ export async function readCanonicalRollupBuckets(opts: {
CASE WHEN COUNT(c."sum_x") = COUNT(*) AND COUNT(c."sum_xy") = COUNT(*)
AND COUNT(c."sum_xx") = COUNT(*) AND COUNT(c."sum_yy") = COUNT(*)
THEN SUM(c."sum_yy") END::double precision AS sum_yy,
MIN(c."bucket_start") AS first_day,
MAX(c."bucket_start") AS last_day,
MAX(c."computed_at") AS computed_at
FROM canon c
GROUP BY 1
@@ -715,7 +766,7 @@ export async function readCanonicalRollupBuckets(opts: {
const toMs = to?.getTime() ?? null;
const weighting = windowWeighting(type);
return rows
.map((r) => composeFoldedBucket(r, weighting))
.map((r) => composeFoldedBucket(r, granularity, weighting))
.filter((b) => {
const start = b.bucketStart.getTime();
if (start < fromMs) return false;
@@ -137,12 +137,13 @@ describe("GET /api/analytics?slice=summaries (C1)", () => {
const prisma = getPrismaClient();
const user = await seedSession("slim-populated-user");
// 14 descending-WEIGHT entries spaced one day apart. Slope per
// day = (84.0 - 80.0) / 13 ≈ -0.308 — solidly inside the "down"
// band (|slope| >= 0.01 units/day threshold).
// 21 descending-WEIGHT entries spaced one day apart. Slope per
// day = (84.0 - 80.0) / 20 = -0.2 — solidly inside the "down"
// band (|slope| >= 0.01 units/day threshold). Twenty days of span
// clear the 15 a 30-day window needs before it carries a trend.
const now = Date.now();
const DAY_MS = 24 * 60 * 60 * 1000;
const N = 14;
const N = 21;
const rows = Array.from({ length: N }, (_, i) => ({
userId: user.id,
type: "WEIGHT" as const,
@@ -167,7 +168,7 @@ describe("GET /api/analytics?slice=summaries (C1)", () => {
expect(weight.count).toBe(N);
// Most-recent row in DESCENDING construction = index 0 = value 80.
expect(weight.latest).toBeCloseTo(80, 1);
// Min / max across the 14 rows.
// Min / max across the 21 rows.
expect(weight.min).toBeCloseTo(80, 1);
expect(weight.max).toBeCloseTo(84, 1);
// 7-day window covers the first 7 entries (descending dates).
@@ -185,6 +186,37 @@ describe("GET /api/analytics?slice=summaries (C1)", () => {
expect(envelope.data.summaries.PULSE.count).toBe(0);
});
it("reports no trend for readings taken minutes apart in one sitting", async () => {
// Three systolic readings 2 and 4 minutes apart, the only ones the
// account holds. REGR_SLOPE over them is about −2,160 mmHg per day;
// the dashboard tile printed seven days of that as its 7-day trend.
const prisma = getPrismaClient();
const user = await seedSession("slim-one-sitting-user");
const start = Date.now() - 30 * 60 * 1000;
await prisma.measurement.createMany({
data: [130, 124, 118].map((value, i) => ({
userId: user.id,
type: "BLOOD_PRESSURE_SYS" as const,
value,
unit: "mmHg",
source: "MANUAL" as const,
measuredAt: new Date(start + i * 2 * 60 * 1000),
})),
});
const { GET } = await import("@/app/api/analytics/route");
const response = await (
GET as unknown as (req: Request) => Promise<Response>
)(new Request("http://localhost/api/analytics?slice=summaries"));
expect(response.status).toBe(200);
const sys = ((await response.json()) as SlimEnvelope).data.summaries
.BLOOD_PRESSURE_SYS;
expect(sys.count).toBe(3);
expect(sys.avg7).toBeCloseTo(124, 1);
expect(sys.slope7).toBeNull();
expect(sys.slope30).toBeNull();
});
it("summarises sleep over the whole history, not the last year", async () => {
const prisma = getPrismaClient();
const user = await seedSession("slim-sleep-history");
@@ -101,11 +101,7 @@
"median": 24.6,
"avg7": 24,
"avg30": 24.28,
"slope7": {
"slope": -0.014,
"direction": "down",
"confidence": 1
},
"slope7": null,
"slope30": {
"slope": -0.021,
"direction": "down",
@@ -222,11 +218,7 @@
"direction": "down",
"confidence": 0.01
},
"slope30": {
"slope": -0.018,
"direction": "down",
"confidence": 0
},
"slope30": null,
"avg30LastMonth": null,
"avg30LastYear": null
},
@@ -101,11 +101,7 @@
"median": 24.6,
"avg7": 24,
"avg30": 24.28,
"slope7": {
"slope": -0.014,
"direction": "down",
"confidence": 1
},
"slope7": null,
"slope30": {
"slope": -0.021,
"direction": "down",
@@ -222,11 +218,7 @@
"direction": "down",
"confidence": 0.01
},
"slope30": {
"slope": -0.018,
"direction": "down",
"confidence": 0
},
"slope30": null,
"avg30LastMonth": null,
"avg30LastYear": null
},
@@ -101,11 +101,7 @@
"median": 24.6,
"avg7": 24,
"avg30": 24.28,
"slope7": {
"slope": -0.014,
"direction": "down",
"confidence": 1
},
"slope7": null,
"slope30": {
"slope": -0.021,
"direction": "down",
@@ -222,11 +218,7 @@
"direction": "down",
"confidence": 0.01
},
"slope30": {
"slope": -0.018,
"direction": "down",
"confidence": 0
},
"slope30": null,
"avg30LastMonth": null,
"avg30LastYear": null
},
@@ -101,11 +101,7 @@
"median": 24.6,
"avg7": 24,
"avg30": 24.28,
"slope7": {
"slope": -0.014,
"direction": "down",
"confidence": 1
},
"slope7": null,
"slope30": {
"slope": -0.021,
"direction": "down",
@@ -222,11 +218,7 @@
"direction": "down",
"confidence": 0.01
},
"slope30": {
"slope": -0.018,
"direction": "down",
"confidence": 0
},
"slope30": null,
"avg30LastMonth": null,
"avg30LastYear": null
},
@@ -101,11 +101,7 @@
"median": 24.6,
"avg7": 24,
"avg30": 24.28,
"slope7": {
"slope": -0.014,
"direction": "down",
"confidence": 1
},
"slope7": null,
"slope30": {
"slope": -0.021,
"direction": "down",
@@ -178,11 +174,7 @@
"direction": "down",
"confidence": 0.01
},
"slope30": {
"slope": -0.018,
"direction": "down",
"confidence": 0
},
"slope30": null,
"avg30LastMonth": null,
"avg30LastYear": null
},
@@ -101,11 +101,7 @@
"median": 24.6,
"avg7": 24,
"avg30": 24.28,
"slope7": {
"slope": -0.014,
"direction": "down",
"confidence": 1
},
"slope7": null,
"slope30": {
"slope": -0.021,
"direction": "down",
@@ -178,11 +174,7 @@
"direction": "down",
"confidence": 0.01
},
"slope30": {
"slope": -0.018,
"direction": "down",
"confidence": 0
},
"slope30": null,
"avg30LastMonth": null,
"avg30LastYear": null
},
@@ -197,11 +197,7 @@
"direction": "down",
"confidence": 0.01
},
"slope30": {
"slope": -0.016,
"direction": "down",
"confidence": 0
},
"slope30": null,
"anomalyCount": 0,
"avg30LastMonth": null,
"avg30LastYear": null
@@ -197,11 +197,7 @@
"direction": "down",
"confidence": 0.01
},
"slope30": {
"slope": -0.016,
"direction": "down",
"confidence": 0
},
"slope30": null,
"anomalyCount": 0,
"avg30LastMonth": null,
"avg30LastYear": null
@@ -197,11 +197,7 @@
"direction": "down",
"confidence": 0.01
},
"slope30": {
"slope": -0.016,
"direction": "down",
"confidence": 0
},
"slope30": null,
"anomalyCount": 0,
"avg30LastMonth": null,
"avg30LastYear": null
@@ -197,11 +197,7 @@
"direction": "down",
"confidence": 0.01
},
"slope30": {
"slope": -0.016,
"direction": "down",
"confidence": 0
},
"slope30": null,
"anomalyCount": 0,
"avg30LastMonth": null,
"avg30LastYear": null
@@ -197,11 +197,7 @@
"direction": "down",
"confidence": 0.01
},
"slope30": {
"slope": -0.016,
"direction": "down",
"confidence": 0
},
"slope30": null,
"anomalyCount": 0,
"avg30LastMonth": null,
"avg30LastYear": null
@@ -197,11 +197,7 @@
"direction": "down",
"confidence": 0.01
},
"slope30": {
"slope": -0.016,
"direction": "down",
"confidence": 0
},
"slope30": null,
"anomalyCount": 0,
"avg30LastMonth": null,
"avg30LastYear": null