mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
commit
cf7302b50a
38 files changed
+1118
-268
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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 ────────────────────────────────────
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
+2
-10
@@ -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
|
||||
},
|
||||
|
||||
+2
-10
@@ -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
|
||||
},
|
||||
|
||||
+2
-10
@@ -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
|
||||
},
|
||||
|
||||
+2
-10
@@ -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
|
||||
},
|
||||
|
||||
+1
-5
@@ -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
|
||||
|
||||
+1
-5
@@ -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
|
||||
|
||||
+1
-5
@@ -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
|
||||
|
||||
+1
-5
@@ -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
|
||||
|
||||
+1
-5
@@ -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
|
||||
|
||||
+1
-5
@@ -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
|
||||
|
||||
Reference in new issue
Block a user