diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d4954a705..3fa11ca47 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -66753,9 +66753,11 @@ components: - direction - confidence additionalProperties: false - description: A linear trend over a trailing window. The window is anchored on NOW, not on the newest reading, so a + description: "A linear trend over a trailing window. The window is anchored on NOW, not on the newest reading, so a series that stopped weeks ago reports `null` for the slope instead of describing a trend that has not been - observed since. + observed since. It is also `null` when the window's readings fall on fewer than 3 calendar days (UTC) or when + their first and last day lie less than half the window apart (4 days for the 7-day window, 15 for the 30-day + one): readings minutes apart cannot carry a slope per day." AnalyticsCorrelations: type: object properties: diff --git a/e2e/tablet-shell.spec.ts b/e2e/tablet-shell.spec.ts index 66d3fc6fe..905baba36 100644 --- a/e2e/tablet-shell.spec.ts +++ b/e2e/tablet-shell.spec.ts @@ -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 `
`), 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 `
` (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); + }); +} diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 60a3e75c8..5238dd4b8 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 89, - "total": 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, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 9f83ea136..067438902 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 352, - "total": 113991, + "total": 113987, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -262,7 +262,7 @@ "lib/analytics/correlations-fast-path.ts :: computeCorrelationHypothesesFastPath": 256, "lib/analytics/intraday-pulse-io.ts :: loadIntradayPulse": 206, "lib/analytics/score/reader.ts :: computeUserHealthScore": 324, - "lib/analytics/summaries-slice.ts :: computeFromRollups": 377, + "lib/analytics/summaries-slice.ts :: computeFromRollups": 375, "lib/api-handler.ts :: apiHandler": 207, "lib/api-handler.ts :: apiHandler > wrapped": 201, "lib/auth/profile-update.ts :: applyProfileUpdate": 353, @@ -295,7 +295,7 @@ "lib/illness/correlation.ts :: computeIllnessCorrelation": 233, "lib/import/labs-csv.ts :: importLabsCsv": 309, "lib/import/labs-csv.ts :: importLabsCsv > flush": 231, - "lib/insights/comprehensive-aggregator.ts :: buildFromRollups": 220, + "lib/insights/comprehensive-aggregator.ts :: buildFromRollups": 218, "lib/insights/comprehensive-generate.ts :: generateComprehensiveInsight": 876, "lib/insights/features.ts :: extractFeatures": 796, "lib/insights/mood-aggregates.ts :: fetchMoodAggregates": 246, diff --git a/src/app/api/analytics/__tests__/route.test.ts b/src/app/api/analytics/__tests__/route.test.ts index e226ffcd1..068af6252 100644 --- a/src/app/api/analytics/__tests__/route.test.ts +++ b/src/app/api/analytics/__tests__/route.test.ts @@ -333,6 +333,11 @@ describe("GET /api/analytics", () => { slope30: -0.02, r2_30: 0.3, r2_90: 0.2, + // Twelve readings on twelve days across the month. + days7: 3, + span7: 5, + days30: 12, + span30: 27, }, ] as never) .mockResolvedValueOnce([ diff --git a/src/app/globals.css b/src/app/globals.css index b8dcc6df5..979d6311e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -631,10 +631,13 @@ body, * owns the shell's bottom edge (the rail, `
`, 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 `
` 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; + } } /* diff --git a/src/components/charts/__tests__/trend-card-7d-trend.test.tsx b/src/components/charts/__tests__/trend-card-7d-trend.test.tsx index 7c01a2bf1..b87a58872 100644 --- a/src/components/charts/__tests__/trend-card-7d-trend.test.tsx +++ b/src/components/charts/__tests__/trend-card-7d-trend.test.tsx @@ -5,6 +5,8 @@ import { Activity, Smile } from "lucide-react"; import { TrendCard } from "../trend-card"; import { I18nProvider } from "@/lib/i18n/context"; import { primeLocales } from "@/__tests__/helpers/prime-locales"; +import { summarize } from "@/lib/analytics/trends"; +import { summaryToTrend7Delta } from "@/lib/analytics/trend-delta"; primeLocales("de"); @@ -166,3 +168,42 @@ describe(" 7-day trend label", () => { expect(html).not.toContain("text-warning"); }); }); + +/** + * The blood-pressure tile read "7-Tage-Trend: 125 (−11.412)" when the + * week's only readings were one sitting minutes apart: the regression's + * slope per day exploded and the tile printed seven days of it. The tile is + * fed the way `strip-tiles.tsx` feeds it, from `summarize()` through + * `summaryToTrend7Delta()`, so the delta it renders is the one the + * dashboard would. + */ +describe(" blood-pressure tile after one sitting", () => { + it("renders the 7-day mean, not a thousands-of-mmHg trend", () => { + const MINUTE = 60 * 1000; + const summary = summarize([ + { date: new Date(Date.now() - 10 * MINUTE), value: 130 }, + { date: new Date(Date.now() - 8 * MINUTE), value: 124 }, + { date: new Date(Date.now() - 6 * MINUTE), value: 118 }, + ]); + const html = render( + , + "de", + ); + expect(html).not.toContain('data-slot="trend7-delta"'); + expect(html).not.toContain("7-Tage-Trend"); + expect(html).toContain("7T"); + // No grouped thousands anywhere on the tile. + expect(html).not.toMatch(/\d\.\d{3}/); + }); +}); diff --git a/src/components/layout/__tests__/shell-viewport.test.ts b/src/components/layout/__tests__/shell-viewport.test.ts index 28e5ebfa7..76778853f 100644 --- a/src/components/layout/__tests__/shell-viewport.test.ts +++ b/src/components/layout/__tests__/shell-viewport.test.ts @@ -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(/ { + 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"); + }); +}); diff --git a/src/components/layout/__tests__/sidebar-nav.test.tsx b/src/components/layout/__tests__/sidebar-nav.test.tsx index 6243311c0..c6d519608 100644 --- a/src/components/layout/__tests__/sidebar-nav.test.tsx +++ b/src/components/layout/__tests__/sidebar-nav.test.tsx @@ -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(" 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, ); diff --git a/src/components/layout/shell-metrics.ts b/src/components/layout/shell-metrics.ts index fba668a0e..c415c51d1 100644 --- a/src/components/layout/shell-metrics.ts +++ b/src/components/layout/shell-metrics.ts @@ -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 + * `