diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d4954a705..3fa11ca47 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index e315e742b..e5fc94533 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -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, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 9f83ea136..067438902 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -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, diff --git a/src/app/api/analytics/__tests__/route.test.ts b/src/app/api/analytics/__tests__/route.test.ts index e226ffcd1..068af6252 100644 --- a/src/app/api/analytics/__tests__/route.test.ts +++ b/src/app/api/analytics/__tests__/route.test.ts @@ -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([ diff --git a/src/components/charts/__tests__/trend-card-7d-trend.test.tsx b/src/components/charts/__tests__/trend-card-7d-trend.test.tsx index 7c01a2bf1..b87a58872 100644 --- a/src/components/charts/__tests__/trend-card-7d-trend.test.tsx +++ b/src/components/charts/__tests__/trend-card-7d-trend.test.tsx @@ -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(" 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(" 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( + , + "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}/); + }); +}); diff --git a/src/lib/analytics/__tests__/summaries-slice.test.ts b/src/lib/analytics/__tests__/summaries-slice.test.ts index 1dfaa998f..9d3f85e30 100644 --- a/src/lib/analytics/__tests__/summaries-slice.test.ts +++ b/src/lib/analytics/__tests__/summaries-slice.test.ts @@ -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. diff --git a/src/lib/analytics/__tests__/trends.test.ts b/src/lib/analytics/__tests__/trends.test.ts index d9242f3a5..21fc29943 100644 --- a/src/lib/analytics/__tests__/trends.test.ts +++ b/src/lib/analytics/__tests__/trends.test.ts @@ -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([]); diff --git a/src/lib/analytics/__tests__/window-confidence.test.ts b/src/lib/analytics/__tests__/window-confidence.test.ts index b5be72e15..e0ea162b3 100644 --- a/src/lib/analytics/__tests__/window-confidence.test.ts +++ b/src/lib/analytics/__tests__/window-confidence.test.ts @@ -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); + }); +}); diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index 10ea07b03..ffa9a976b 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -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 { ) { 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 { 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, }; diff --git a/src/lib/analytics/trend-delta.ts b/src/lib/analytics/trend-delta.ts index 99aed9140..9408e32e2 100644 --- a/src/lib/analytics/trend-delta.ts +++ b/src/lib/analytics/trend-delta.ts @@ -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"; diff --git a/src/lib/analytics/trend-evidence-sql.ts b/src/lib/analytics/trend-evidence-sql.ts new file mode 100644 index 000000000..ed91942ee --- /dev/null +++ b/src/lib/analytics/trend-evidence-sql.ts @@ -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, + ); +} diff --git a/src/lib/analytics/trends.ts b/src/lib/analytics/trends.ts index 71fbda8be..bedc2bd55 100644 --- a/src/lib/analytics/trends.ts +++ b/src/lib/analytics/trends.ts @@ -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 ──────────────────────────────────── diff --git a/src/lib/analytics/window-confidence.ts b/src/lib/analytics/window-confidence.ts index e50b5e3ba..836b4c665 100644 --- a/src/lib/analytics/window-confidence.ts +++ b/src/lib/analytics/window-confidence.ts @@ -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): TrendEvidence { + let first = Infinity; + let last = -Infinity; + const days = new Set(); + 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 }; +} diff --git a/src/lib/insights/__tests__/comprehensive-aggregator.test.ts b/src/lib/insights/__tests__/comprehensive-aggregator.test.ts index cb1c13dfb..8e1bcea80 100644 --- a/src/lib/insights/__tests__/comprehensive-aggregator.test.ts +++ b/src/lib/insights/__tests__/comprehensive-aggregator.test.ts @@ -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 () => { diff --git a/src/lib/insights/comprehensive-aggregator.ts b/src/lib/insights/comprehensive-aggregator.ts index 553952565..7c7926ee6 100644 --- a/src/lib/insights/comprehensive-aggregator.ts +++ b/src/lib/insights/comprehensive-aggregator.ts @@ -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, diff --git a/src/lib/openapi/routes/analytics.ts b/src/lib/openapi/routes/analytics.ts index 5bbe0f52a..de913c2e5 100644 --- a/src/lib/openapi/routes/analytics.ts +++ b/src/lib/openapi/routes/analytics.ts @@ -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 diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index a088d8968..d18bb5dd4 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -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() diff --git a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts index 44a9c1d06..738288795 100644 --- a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts +++ b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts @@ -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 () => { diff --git a/src/lib/rollups/measurement-read.ts b/src/lib/rollups/measurement-read.ts index ede087a84..26780b4d1 100644 --- a/src/lib/rollups/measurement-read.ts +++ b/src/lib/rollups/measurement-read.ts @@ -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, + 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, 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, 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; diff --git a/tests/integration/analytics-summaries-slice.test.ts b/tests/integration/analytics-summaries-slice.test.ts index 7ff10e366..7c88560a8 100644 --- a/tests/integration/analytics-summaries-slice.test.ts +++ b/tests/integration/analytics-summaries-slice.test.ts @@ -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 + )(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"); diff --git a/tests/integration/routes/parity/__snapshots__/analytics.02-summaries-cold.json.snap b/tests/integration/routes/parity/__snapshots__/analytics.02-summaries-cold.json.snap index 03b0f8029..ff13f7158 100644 --- a/tests/integration/routes/parity/__snapshots__/analytics.02-summaries-cold.json.snap +++ b/tests/integration/routes/parity/__snapshots__/analytics.02-summaries-cold.json.snap @@ -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 }, diff --git a/tests/integration/routes/parity/__snapshots__/analytics.03-summaries-warm.json.snap b/tests/integration/routes/parity/__snapshots__/analytics.03-summaries-warm.json.snap index 9eaff45da..5a7a94006 100644 --- a/tests/integration/routes/parity/__snapshots__/analytics.03-summaries-warm.json.snap +++ b/tests/integration/routes/parity/__snapshots__/analytics.03-summaries-warm.json.snap @@ -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 }, diff --git a/tests/integration/routes/parity/__snapshots__/analytics.04-default-cold.json.snap b/tests/integration/routes/parity/__snapshots__/analytics.04-default-cold.json.snap index 013125ba9..b53383052 100644 --- a/tests/integration/routes/parity/__snapshots__/analytics.04-default-cold.json.snap +++ b/tests/integration/routes/parity/__snapshots__/analytics.04-default-cold.json.snap @@ -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 }, diff --git a/tests/integration/routes/parity/__snapshots__/analytics.05-default-warm.json.snap b/tests/integration/routes/parity/__snapshots__/analytics.05-default-warm.json.snap index 528935f1e..c84149b13 100644 --- a/tests/integration/routes/parity/__snapshots__/analytics.05-default-warm.json.snap +++ b/tests/integration/routes/parity/__snapshots__/analytics.05-default-warm.json.snap @@ -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 }, diff --git a/tests/integration/routes/parity/__snapshots__/analytics.06-summaries-modules-off.json.snap b/tests/integration/routes/parity/__snapshots__/analytics.06-summaries-modules-off.json.snap index 94952320a..2e0f796dc 100644 --- a/tests/integration/routes/parity/__snapshots__/analytics.06-summaries-modules-off.json.snap +++ b/tests/integration/routes/parity/__snapshots__/analytics.06-summaries-modules-off.json.snap @@ -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 }, diff --git a/tests/integration/routes/parity/__snapshots__/analytics.07-default-de-personal-target.json.snap b/tests/integration/routes/parity/__snapshots__/analytics.07-default-de-personal-target.json.snap index 5c93599a8..e19efe263 100644 --- a/tests/integration/routes/parity/__snapshots__/analytics.07-default-de-personal-target.json.snap +++ b/tests/integration/routes/parity/__snapshots__/analytics.07-default-de-personal-target.json.snap @@ -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 }, diff --git a/tests/integration/routes/parity/__snapshots__/insights-comprehensive.02-cold.json.snap b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.02-cold.json.snap index 44932c375..4b530c118 100644 --- a/tests/integration/routes/parity/__snapshots__/insights-comprehensive.02-cold.json.snap +++ b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.02-cold.json.snap @@ -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 diff --git a/tests/integration/routes/parity/__snapshots__/insights-comprehensive.03-warm.json.snap b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.03-warm.json.snap index cdd109367..59b90c4a4 100644 --- a/tests/integration/routes/parity/__snapshots__/insights-comprehensive.03-warm.json.snap +++ b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.03-warm.json.snap @@ -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 diff --git a/tests/integration/routes/parity/__snapshots__/insights-comprehensive.04-stale.json.snap b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.04-stale.json.snap index f2a85fa5b..4c2d5610a 100644 --- a/tests/integration/routes/parity/__snapshots__/insights-comprehensive.04-stale.json.snap +++ b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.04-stale.json.snap @@ -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 diff --git a/tests/integration/routes/parity/__snapshots__/insights-comprehensive.05-ai-analysis-off.json.snap b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.05-ai-analysis-off.json.snap index 7235cd05c..84ce21fcf 100644 --- a/tests/integration/routes/parity/__snapshots__/insights-comprehensive.05-ai-analysis-off.json.snap +++ b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.05-ai-analysis-off.json.snap @@ -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 diff --git a/tests/integration/routes/parity/__snapshots__/insights-comprehensive.06-operator-off.json.snap b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.06-operator-off.json.snap index f9b98e98f..6f3b33796 100644 --- a/tests/integration/routes/parity/__snapshots__/insights-comprehensive.06-operator-off.json.snap +++ b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.06-operator-off.json.snap @@ -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 diff --git a/tests/integration/routes/parity/__snapshots__/insights-comprehensive.07-manage-delegate.json.snap b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.07-manage-delegate.json.snap index 06218e2c0..8f8473808 100644 --- a/tests/integration/routes/parity/__snapshots__/insights-comprehensive.07-manage-delegate.json.snap +++ b/tests/integration/routes/parity/__snapshots__/insights-comprehensive.07-manage-delegate.json.snap @@ -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