Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-ai

# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
This commit is contained in:
Marc-André Bombeck committed 2026-10-24 11:29:56 +02:00
commit cf7302b50a
38 files changed
+1118 -268

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:
+116
View File
@@ -610,3 +610,119 @@ for (const vp of [
});
}
}
/**
* A side inset. An iPad in a Safari tab laid the shell out 10 px in from the
* left edge in portrait, which no browser under test reports. The shell and
* the rail read the side insets through `--safe-area-left` / `-right`
* (globals.css), so setting them is the same inset to both.
*/
const SIDE_INSET = 10;
async function setSideInsets(page: Page, px: number) {
await page.addStyleTag({
content: `:root { --safe-area-left: ${px}px !important; --safe-area-right: ${px}px !important; }`,
});
}
const SIDE_INSET_VIEWPORTS = [
{ name: "iPad Pro 11 portrait", width: 834, height: 1194 },
{ name: "iPad Air 11 portrait", width: 820, height: 1180 },
{ name: "iPad Air 11 landscape", width: 1180, height: 820 },
{ name: "iPad Pro 13 landscape", width: 1366, height: 1024 },
] as const;
for (const vp of SIDE_INSET_VIEWPORTS) {
test(`${vp.name}: the rail runs to the left edge over a side inset`, async ({
page,
}) => {
await page.setViewportSize({ width: vp.width, height: vp.height });
await openDashboard(page);
await setSideInsets(page, SIDE_INSET);
await setBottomInset(page, HOME_INDICATOR);
await expectShellPinned(page);
await expect(page.locator("aside")).toBeVisible();
const m = await page.evaluate(() => {
const aside = document.querySelector("aside")!;
const box = (el: Element | null) => {
const r = el!.getBoundingClientRect();
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom };
};
// What paints a pixel: the rail, or the page behind the shell (the
// root, the body, or one of the shell's own unpainted boxes around the
// rail and `<main>`), or some other chrome that meets the edge.
const owner = (x: number, y: number) => {
const hit = document.elementFromPoint(x, y);
if (!hit) return "nothing";
if (hit.closest("aside")) return "rail";
const unpainted =
hit === document.documentElement ||
hit === document.body ||
(hit.id !== "main-content" &&
hit.contains(document.getElementById("main-content")));
return unpainted ? `page:${hit.tagName}` : "chrome";
};
const header = aside.querySelector('[data-slot="sidebar-header"]')!;
const account = aside.querySelector(
'[data-slot="sidebar-column"]',
)!.lastElementChild!;
const avatar = account.querySelector('[data-slot="avatar"]')!;
const footer = aside.querySelector('[data-slot="sidebar-footer"]')!;
const links = [...aside.querySelectorAll("nav a[href]")].map(
(a) => a.getBoundingClientRect().left,
);
const headerBox = box(header);
const accountBox = box(account);
return {
innerWidth,
innerHeight,
aside: box(aside),
header: headerBox,
account: accountBox,
avatar: box(avatar),
logoLeft: box(header.querySelector("svg")).left,
footerLeft: box(footer).left,
firstLinkLeft: Math.min(...links),
// Down the window's first column of pixels, and its last.
leftEdge: [
(headerBox.top + headerBox.bottom) / 2,
innerHeight / 2,
(accountBox.top + accountBox.bottom) / 2,
innerHeight - 2,
].map((y) => owner(1, y)),
rightEdge: [
(headerBox.top + headerBox.bottom) / 2,
innerHeight / 2,
].map((y) => owner(innerWidth - 1, y)),
};
});
// No strip: the rail paints the inset from the logo band to the bottom
// edge, and the top bar and `<main>` (or a docked panel) meet the right
// edge.
expect(m.leftEdge).toEqual(["rail", "rail", "rail", "rail"]);
expect(m.rightEdge).toEqual(["chrome", "chrome"]);
expect(m.aside.left).toBe(0);
// The band lines run to the edge: the logo band, the account band and
// the footer group (whose scroll edge draws a line) start at x = 0.
expect(m.header.left).toBe(0);
expect(m.account.left).toBe(0);
expect(m.footerLeft).toBe(0);
// And everything in the rail stays clear of the inset.
expect(m.logoLeft).toBeGreaterThanOrEqual(SIDE_INSET);
expect(m.firstLinkLeft).toBeGreaterThanOrEqual(SIDE_INSET);
expect(m.avatar.left).toBeGreaterThanOrEqual(SIDE_INSET);
// The account band mirrors the logo band: the same height, the avatar
// centred in it, and the home indicator's inset below it.
expect(m.account.bottom - m.account.top).toBe(
m.header.bottom - m.header.top,
);
const above = m.avatar.top - m.account.top;
const below = m.account.bottom - m.avatar.bottom;
expect(Math.abs(above - below)).toBeLessThanOrEqual(1);
expect(m.account.bottom).toBe(m.innerHeight - HOME_INDICATOR);
expect(m.aside.bottom).toBe(m.innerHeight);
});
}
@@ -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": 99397,
"total": 99364,
"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([
+27 -8
View File
@@ -631,10 +631,13 @@ body,
* owns the shell's bottom edge (the rail, `<main>`, the bottom bar, the
* Coach button). It is `env()` everywhere it matters; a test that needs an
* inset no desktop browser reports sets it instead
* (`e2e/tablet-shell.spec.ts`).
* (`e2e/tablet-shell.spec.ts`). The side insets are read the same way, by
* the shell and the rail (`.shell-safe-area` below).
*/
:root {
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-left: env(safe-area-inset-left, 0px);
--safe-area-right: env(safe-area-inset-right, 0px);
}
html[data-scroll="body"] {
scrollbar-gutter: stable;
@@ -691,18 +694,34 @@ body,
@layer components {
/*
* `.shell-safe-area` — the authenticated app shell's share of the installed
* app's safe area (see the note in `auth-shell.tsx`). The shell takes the top
* and side insets as padding, and the strip behind the status bar is painted
* in the chrome colour (`--card`, the top bar and the sidebar) rather than
* the page colour, so the status bar reads as part of the bar beneath it. A
* zero inset makes the gradient zero tall: nothing changes in a browser tab.
* app's safe area (see the note in `auth-shell.tsx`). The shell takes the
* top inset as padding (and the side insets in the phone shell), and the
* strip behind the status bar is painted in the chrome colour (`--card`,
* the top bar and the sidebar) rather than the page colour, so the status
* bar reads as part of the bar beneath it. A zero inset makes the gradient
* zero tall.
*
* The side insets are not always zero in a browser tab: an iPad in a Safari
* tab laid the shell out 10 px in from the left edge in portrait, with
* Safari's own toolbar buttons moved in by the same 10 px. Padded here, that
* inset showed the page colour in a strip left of the rail, from the logo
* band down to the account row, and cut the rail's band borders short of the
* edge. So the desktop shell leaves the sides to what meets them: the rail
* takes the left inset inside its own background (`sidebar-nav.tsx`), and
* on the right the top bar and `<main>` run to the edge with gutters wider
* than any tablet's side inset. The phone shell has no rail and keeps them
* here.
*/
.shell-safe-area {
padding-top: env(safe-area-inset-top, 0px);
padding-right: env(safe-area-inset-right, 0px);
padding-left: env(safe-area-inset-left, 0px);
padding-right: var(--safe-area-right);
padding-left: var(--safe-area-left);
background: linear-gradient(var(--card), var(--card)) top / 100%
env(safe-area-inset-top, 0px) no-repeat;
@variant shell-desktop {
padding-right: 0;
padding-left: 0;
}
}
/*
@@ -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}/);
});
});
@@ -12,6 +12,10 @@ import { readSource } from "@/__tests__/helpers/source-files";
import {
MAIN_SCROLL_CLASS,
RAIL_ACCOUNT_BAND,
RAIL_WIDTH_COLLAPSED,
RAIL_WIDTH_EXPANDED,
SHELL_HEADER_BAND,
SHELL_ROOT_CLASS,
SUB_SHELL_GRID_FLOOR,
} from "../shell-metrics";
@@ -137,3 +141,53 @@ describe("the bottom edge is cleared in both shells", () => {
);
});
});
/**
* The side insets. An iPad in a Safari tab laid the shell out 10 px in from
* the left edge in portrait; the shell padded that inset and its strip showed
* the page colour beside the rail, from the logo band to the account row.
* Desktop browsers report no side inset, so `e2e/tablet-shell.spec.ts`
* simulates one through `--safe-area-left` and measures the result; these
* hold the decisions that route the inset to the rail.
*/
describe("the side insets belong to what meets them", () => {
const css = () => readSource("src/app/globals.css", ".shell-safe-area {");
const rail = () =>
readSource(
"src/components/layout/sidebar-nav.tsx",
"export function SidebarNav",
);
it("are env() behind one property each, which the shell and the rail read", () => {
const root = readSource("src/app/globals.css", "--safe-area-left");
expect(root).toMatch(
/--safe-area-left:\s*env\(safe-area-inset-left,\s*0px\);/,
);
expect(root).toMatch(
/--safe-area-right:\s*env\(safe-area-inset-right,\s*0px\);/,
);
expect(css()).not.toMatch(/padding-(?:left|right):\s*env\(/);
});
it("the desktop shell pads no side, the phone shell pads both", () => {
const block = /\.shell-safe-area\s*\{([\s\S]*?)\n \}/.exec(css())![1];
expect(block).toMatch(/padding-left:\s*var\(--safe-area-left\);/);
expect(block).toMatch(/padding-right:\s*var\(--safe-area-right\);/);
expect(block).toMatch(
/@variant shell-desktop\s*\{\s*padding-right:\s*0;\s*padding-left:\s*0;\s*\}/,
);
});
it("the rail takes the left inset inside its own background", () => {
const source = rail();
expect(source).toMatch(/<aside[\s\S]*?pl-\(--safe-area-left\)/);
expect(RAIL_WIDTH_COLLAPSED).toBe("w-[calc(4rem+var(--safe-area-left))]");
expect(RAIL_WIDTH_EXPANDED).toBe("w-[calc(16rem+var(--safe-area-left))]");
});
it("the account band mirrors the logo band", () => {
const height = (value: string) => /\bh-(\d+)\b/.exec(value)?.[1];
expect(height(RAIL_ACCOUNT_BAND)).toBe(height(SHELL_HEADER_BAND));
expect(RAIL_ACCOUNT_BAND).toContain("border-t");
});
});
@@ -56,6 +56,7 @@ vi.mock("@/hooks/use-mounted", () => ({
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { TooltipProvider } from "@/components/ui/tooltip";
import { RAIL_WIDTH_COLLAPSED } from "../shell-metrics";
import {
SIDEBAR_EXPANDED_CONTENT_WIDTH,
SidebarCollapseToggle,
@@ -559,7 +560,7 @@ describe("<SidebarNav> width transition never overflows the nav", () => {
});
mockMountedRef.value = true;
const html = render();
expect(asideClass(html).split(" ")).toContain("w-16");
expect(asideClass(html).split(" ")).toContain(RAIL_WIDTH_COLLAPSED);
expect(columnClass(html).split(" ")).not.toContain(
SIDEBAR_EXPANDED_CONTENT_WIDTH,
);
+49
View File
@@ -91,3 +91,52 @@ export const MAIN_SCROLL_CLASS =
* widths in both the expanded and the collapsed rail.
*/
export const SHELL_HEADER_BAND = "h-16 border-b";
/**
* The rail's account band, the bottom counterpart of its logo band: the same
* height, the border on the other side, the avatar centred in it. Padded on
* its own (`p-3`, `p-2` on short windows) it came out 61 to 69 px tall with
* 18 px above the avatar and more below, and its divider sat at no height
* the logo band shares. The home-indicator inset stays under the band (the
* rail's `pb-(--safe-area-bottom)`), so the band itself never shifts.
*/
export const RAIL_ACCOUNT_BAND = "h-16 shrink-0 border-t";
/**
* The rail's width, collapsed (4rem) and expanded (16rem), plus the window's
* left inset. The rail runs to the window's left edge and takes that inset as
* padding inside its own background (`pl-(--safe-area-left)` on the
* `<aside>`), so its content keeps the 4rem / 16rem it was laid out for and
* stays clear of the inset. The shell leaves the left side to the rail
* (`.shell-safe-area`, globals.css): an iPad in a Safari tab laid the shell
* out 10 px in from the left, and that strip showed the page colour beside
* the rail. Zero inset: exactly `w-16` / `w-64`.
*/
export const RAIL_WIDTH_COLLAPSED = "w-[calc(4rem+var(--safe-area-left))]";
export const RAIL_WIDTH_EXPANDED = "w-[calc(16rem+var(--safe-area-left))]";
/**
* A rail band that draws a border across the rail (the logo band, the footer
* group's scroll edge, the account band) reaches back over the left inset, so
* its line runs to the window's edge like the top bar's does, and adds the
* inset to its own left padding, so its content stays where it was.
*/
const RAIL_BAND_BLEED = "-ml-(--safe-area-left)";
/** The logo band's and the footer group's horizontal padding, inset included. */
export const RAIL_BAND_INSET = {
header: {
collapsed: `${RAIL_BAND_BLEED} pr-3 pl-[calc(0.75rem+var(--safe-area-left))]`,
expanded: `${RAIL_BAND_BLEED} pr-6 pl-[calc(1.5rem+var(--safe-area-left))]`,
},
footer: {
collapsed: `${RAIL_BAND_BLEED} pr-1.5 pl-[calc(0.375rem+var(--safe-area-left))]`,
expanded: `${RAIL_BAND_BLEED} pr-3 pl-[calc(0.75rem+var(--safe-area-left))]`,
},
} as const;
/** The account band, collapsed (the avatar centred) and expanded. */
export const RAIL_ACCOUNT_BAND_CLASS = {
collapsed: `${RAIL_ACCOUNT_BAND} ${RAIL_BAND_BLEED} border-sidebar-border flex items-center justify-center pl-(--safe-area-left)`,
expanded: `${RAIL_ACCOUNT_BAND} ${RAIL_BAND_BLEED} border-sidebar-border flex items-center pr-3 pl-[calc(0.75rem+var(--safe-area-left))]`,
} as const;
+20 -12
View File
@@ -26,7 +26,13 @@ import {
visibleNavDestinations,
visibleUtilityDestinations,
} from "@/components/layout/nav-model";
import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics";
import {
RAIL_ACCOUNT_BAND_CLASS,
RAIL_BAND_INSET,
RAIL_WIDTH_COLLAPSED,
RAIL_WIDTH_EXPANDED,
SHELL_HEADER_BAND,
} from "@/components/layout/shell-metrics";
import {
RailNavList,
useNavOverflows,
@@ -268,10 +274,8 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
if (collapsed) {
return (
<div
className={cn(
"border-sidebar-border border-t p-3",
"[@media(max-height:940px)]:p-2",
)}
data-slot="sidebar-account"
className={RAIL_ACCOUNT_BAND_CLASS.collapsed}
>
<div className="flex items-center justify-center">
<DropdownMenu>
@@ -294,8 +298,11 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
}
return (
<div className="border-sidebar-border border-t p-3">
<div className="flex items-center gap-3 px-2 py-1">
<div
data-slot="sidebar-account"
className={RAIL_ACCOUNT_BAND_CLASS.expanded}
>
<div className="flex w-full min-w-0 items-center gap-3 px-2 py-1">
{/* The avatar + name in the expanded footer routes straight
into the account settings; the kebab to the right still opens
the rest of the user menu. */}
@@ -343,7 +350,8 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
*
* Steps (rows are full-width, so the click target is the whole row):
* - default: 40 px rows, 4 px apart
* - max-height 940 px: gaps close (40 px pitch), list and user padding tighten
* - max-height 940 px: gaps close (40 px pitch), the list padding tightens
* (the account band keeps the logo band's height, `RAIL_ACCOUNT_BAND`)
* - max-height 820 px: 36 px rows
* - max-height 740 px: 32 px rows — the one step under 40 px, taken only
* where the alternative is rows hidden behind a scroll edge
@@ -627,8 +635,8 @@ export function SidebarNav() {
<aside
aria-label={t("nav.sidebar")}
className={cn(
"bg-sidebar border-sidebar-border shell-desktop:flex shell-desktop:flex-col relative hidden h-full flex-shrink-0 overflow-hidden border-r pb-(--safe-area-bottom) transition-[width] duration-200 motion-reduce:transition-none",
collapsed ? "w-16" : "w-64",
"bg-sidebar border-sidebar-border shell-desktop:flex shell-desktop:flex-col relative hidden h-full flex-shrink-0 overflow-hidden border-r pb-(--safe-area-bottom) pl-(--safe-area-left) transition-[width] duration-200 motion-reduce:transition-none",
collapsed ? RAIL_WIDTH_COLLAPSED : RAIL_WIDTH_EXPANDED,
)}
>
{/* The rail animates its width; the content does not. Expanded, this
@@ -656,7 +664,7 @@ export function SidebarNav() {
className={cn(
"border-sidebar-border",
SHELL_HEADER_BAND,
collapsed ? "px-3" : "px-6",
RAIL_BAND_INSET.header[collapsed ? "collapsed" : "expanded"],
)}
>
<Link
@@ -755,7 +763,7 @@ export function SidebarNav() {
className={cn(
RAIL_LIST_GAP,
"pb-1",
collapsed ? "px-1.5" : "px-3",
RAIL_BAND_INSET.footer[collapsed ? "collapsed" : "expanded"],
navOverflows && "border-sidebar-border border-t pt-1",
)}
>
@@ -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