diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3271e670b..331d3d85c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -41066,9 +41066,9 @@ components: properties: id: type: string - description: Measurement row id; `sleep:` for a sleep night; `day:` for a day bucket (`pulse` / - `glucose` beyond 90 days); `hour:` for an hour bucket (`pulse` / `glucose` within 90 days - when the window holds more than 10 000 readings). + description: Measurement row id; `sleep:` for a sleep night; `day:` for a day bucket (`pulse`, + `glucose`, `heartRateVariability`, `oxygenSaturation` beyond 90 days); `hour:` for an hour + bucket (the same kinds within 90 days when the window holds more than 10 000 readings). at: type: string format: date-time @@ -41141,12 +41141,13 @@ components: - stats additionalProperties: false description: "iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an - explicit `unit` token, and a summary `stats` block. Sample-dense kinds are bucketed server-side: `pulse` and - `glucose` beyond 90 days per local day, and `pulse` / `glucose` within 90 days per local hour when the window - holds more than 10 000 readings (a continuous glucose sensor, a watch recording heart rate every minute; a cuff - or fingerstick meter stays raw). A bucket carries the mean as `value`; a `pulse` bucket also carries its range - as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading. `stats` - is always computed over the underlying readings, never over the buckets." + explicit `unit` token, and a summary `stats` block. Sample-dense kinds (`pulse`, `glucose`, + `heartRateVariability`, `oxygenSaturation`) are bucketed server-side: beyond 90 days per local day, and within + 90 days per local hour when the window holds more than 10 000 readings (a continuous glucose sensor, a watch or + ring recording every minute; a cuff, a fingerstick meter or a few readings a night stay raw). A bucket carries + the mean as `value` and otherwise has the shape of a reading of its kind; a `pulse` bucket also carries its + range as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading. + `stats` is always computed over the underlying readings, never over the buckets." ListMeasurementsResponse: type: object properties: @@ -41339,6 +41340,12 @@ components: type: integer minimum: 0 maximum: 9007199254740991 + windowFrom: + description: "Set on the per-night SLEEP_DURATION mode when the requested window was longer than 365 days: the instant + the read started from. The window is a year ending at `to` (or now); page further back by moving `to`." + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - total - limit diff --git a/e2e/dialog-horizontal-overflow.spec.ts b/e2e/dialog-horizontal-overflow.spec.ts index 9a66f45f2..4fed8626f 100644 --- a/e2e/dialog-horizontal-overflow.spec.ts +++ b/e2e/dialog-horizontal-overflow.spec.ts @@ -61,6 +61,8 @@ interface DialogCase { prepare?: (page: import("@playwright/test").Page) => Promise; /** Something inside the dialog that proves it rendered its form, not a shell. */ ready: string; + /** Render in this locale instead of the suite's pinned English. */ + locale?: "de"; } const DIALOGS: DialogCase[] = [ @@ -92,11 +94,23 @@ const DIALOGS: DialogCase[] = [ trigger: /^add$/i, ready: '[data-slot="date-time-field"]', }, + // The labs sheet's footer holds three buttons (Cancel, Save & add another, + // Save). They fit a phone row in English; the German labels are ~100px + // wider and pushed Cancel off the sheet's left edge, at every width. + { + name: "labs add (German)", + path: "/labs", + trigger: /^hinzufügen$/i, + ready: '[data-slot="date-time-field"]', + locale: "de", + }, ]; const VIEWPORTS = [ { label: "desktop 1280", width: 1280, height: 800 }, { label: "phone 390", width: 390, height: 844 }, + // The narrowest common Android width. + { label: "phone 360", width: 360, height: 780 }, ]; /** @@ -200,7 +214,17 @@ test.describe("add/edit dialogs do not scroll sideways", () => { for (const dialog of DIALOGS) { test(`${dialog.name} fits its surface at ${vp.label}`, async ({ page, - }) => { + context, + }, testInfo) => { + if (dialog.locale) { + await context.addCookies([ + { + name: "healthlog-locale", + value: dialog.locale, + url: testInfo.project.use.baseURL ?? "http://localhost:3000", + }, + ]); + } await page.setViewportSize({ width: vp.width, height: vp.height }); await page.goto(dialog.path, { waitUntil: "domcontentloaded" }); diff --git a/e2e/mobile-route-overflow.spec.ts b/e2e/mobile-route-overflow.spec.ts new file mode 100644 index 000000000..2529cacdf --- /dev/null +++ b/e2e/mobile-route-overflow.spec.ts @@ -0,0 +1,311 @@ +import { readdirSync, statSync } from "node:fs"; +import { join, relative, sep } from "node:path"; + +import type { APIRequestContext } from "@playwright/test"; + +import { ADMIN_SECTION_SLUGS } from "@/components/admin/section-slugs"; +import { SETTINGS_SECTION_SLUGS } from "@/components/settings/section-slugs"; + +import { expect, test } from "./setup/test"; +import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup"; +import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture"; +import { + expectNoHorizontalOverflow, + settleForOverflowMeasurement, +} from "./utils/horizontal-overflow"; + +/** + * Every signed-in route, at an iPhone width, does not scroll sideways. + * + * `mobile-horizontal-overflow.spec.ts` sweeps five widths over four + * content-heavy routes. This file is the breadth half: one width, every route. + * It exists because the lab list panned sideways on a phone for weeks while + * the guard was green — `/labs` was simply not on the list. A hand-kept list + * goes stale the day a route is added, so the routes here are read from + * `src/app` itself, and a route this file cannot open fails the run instead of + * dropping out silently: + * + * - a static route is visited as is; + * - a dynamic route needs an entry in `DYNAMIC_ROUTES` that turns its + * segments into concrete paths (a settings slug, a seeded record's id); + * - a route that cannot be swept signed in (auth, invitation, public + * share-link pages) is listed in `NOT_SIGNED_IN` with its reason. + * + * The account is `E2E_MOBILE_ROUTES`, which `globalSetup` fills with one or + * two of everything (`mobile-routes-fixture.ts`): an empty page fits any + * viewport and would prove nothing. It renders in German, the locale with the + * longest common strings, and at 390 px — the iPhone width the lab list broke + * at. + */ + +const APP_DIR = join(process.cwd(), "src", "app"); +const WIDTH = 390; + +/** Route patterns that are not signed-in surfaces, and why. */ +const NOT_SIGNED_IN: Record = { + "/auth/login": "the sign-in page; a session redirects away from it", + "/auth/register": "registration; a session redirects away from it", + "/invite/[token]": + "an invitation landing page for somebody without an account", + "/c/[token]": "the clinician share-link page, opened without a session", + "/confirm-access": "the re-proof page, reached only from a stale session", + "/enroll-mfa": "the forced second-factor enrolment of a policy-bound account", + "/onboarding": "the first-run setup flow; covered by setup-flow-*.spec.ts", + "/onboarding/[step]": + "the first-run setup flow; covered by setup-flow-*.spec.ts", +}; + +type Resolver = (api: APIRequestContext) => Promise; + +async function readData(api: APIRequestContext, path: string): Promise { + const res = await api.get(path); + expect(res.ok(), `${path} answered ${res.status()}`).toBe(true); + return ((await res.json()) as { data: T }).data; +} + +const firstId = (ids: Array, what: string) => { + const id = ids.find((value): value is string => Boolean(value)); + if (!id) + throw new Error(`the fixture holds no ${what} — globalSetup seeds one`); + return id; +}; + +/** Every dynamic route pattern under `src/app`, turned into real paths. */ +const DYNAMIC_ROUTES: Record = { + "/settings/[section]": async () => + SETTINGS_SECTION_SLUGS.map((slug) => `/settings/${slug}`), + "/admin/[section]": async () => + ADMIN_SECTION_SLUGS.map((slug) => `/admin/${slug}`), + // `LAYOUT_GROUP_IDS` lives beside React components the test runner cannot + // load; `settings/layout/[module]` 404s an unknown group, so a stale entry + // here fails on the status check below rather than passing quietly. + "/settings/layout/[module]": async () => + [ + "dashboard", + "insights", + "medications", + "mood", + "labs", + "illness", + "vorsorge", + ].map((group) => `/settings/layout/${group}`), + "/insights/scores/[metric]": async () => + ["sleep", "readiness", "recovery", "stress", "strain"].map( + (metric) => `/insights/scores/${metric}`, + ), + "/insights/values/[type]": async () => [ + "/insights/values/WEIGHT", + "/insights/values/BLOOD_PRESSURE_SYS", + ], + "/labs/[biomarkerId]": async (api) => { + const data = await readData<{ + results: Array<{ biomarkerId: string | null; analyte: string }>; + }>(api, "/api/labs?limit=50"); + const id = firstId( + data.results + .filter((row) => row.analyte === MOBILE_ROUTES_ANALYTE) + .map((row) => row.biomarkerId), + "linked lab reading", + ); + return [`/labs/${id}`]; + }, + "/labs/[biomarkerId]/values": async (api) => + (await DYNAMIC_ROUTES["/labs/[biomarkerId]"](api)).map( + (path) => `${path}/values`, + ), + "/medications/[id]": async (api) => { + const meds = await readData>(api, "/api/medications"); + return [ + `/medications/${firstId( + meds.map((m) => m.id), + "medication", + )}`, + ]; + }, + "/medications/[id]/history": async (api) => + (await DYNAMIC_ROUTES["/medications/[id]"](api)).map( + (path) => `${path}/history`, + ), + "/illness/[id]": async (api) => { + const episodes = await readData>( + api, + "/api/illness/episodes", + ); + return [ + `/illness/${firstId( + episodes.map((e) => e.id), + "illness episode", + )}`, + ]; + }, + "/custom-metrics/[id]": async (api) => { + const data = await readData<{ customMetrics: Array<{ id: string }> }>( + api, + "/api/custom-metrics", + ); + const id = firstId( + data.customMetrics.map((m) => m.id), + "custom metric", + ); + return [`/custom-metrics/${id}`]; + }, + "/custom-metrics/[id]/values": async (api) => + (await DYNAMIC_ROUTES["/custom-metrics/[id]"](api)).map( + (path) => `${path}/values`, + ), + "/insights/workouts/[id]": async (api) => { + const data = await readData<{ workouts: Array<{ id: string }> }>( + api, + "/api/workouts?limit=1", + ); + return [ + `/insights/workouts/${firstId( + data.workouts.map((w) => w.id), + "workout", + )}`, + ]; + }, + // An ECG strip only arrives through a device sync; no route writes one. The + // detail page is swept in its not-found state, which is still the frame, + // header and back link a phone renders. + "/insights/ecg/[id]": async () => ["/insights/ecg/unknown-recording"], +}; + +/** Every `page.tsx` under `src/app`, as a route pattern (`/labs/[biomarkerId]`). */ +function discoverRoutePatterns(dir = APP_DIR): string[] { + const patterns: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + if (entry === "api" || entry.startsWith("_") || entry === "__tests__") + continue; + patterns.push(...discoverRoutePatterns(full)); + } else if (entry === "page.tsx") { + const rel = relative(APP_DIR, dir).split(sep).join("/"); + // Route groups `(name)` do not appear in the URL. + const path = rel + .split("/") + .filter((segment) => segment && !/^\(.*\)$/.test(segment)) + .join("/"); + patterns.push(`/${path}`); + } + } + return patterns.sort(); +} + +const PATTERNS = discoverRoutePatterns(); +const isDynamic = (pattern: string) => pattern.includes("["); + +test.describe("every signed-in route fits a phone", () => { + test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH }); + + test.beforeEach(async ({ context }, testInfo) => { + test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec"); + const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000"; + await context.addCookies([ + { name: "healthlog-locale", value: "de", url: baseURL }, + ]); + }); + + /** + * The bookkeeping the sweep depends on. A new dynamic route without a + * resolver, a resolver for a route that no longer exists, or an exclusion + * for a route that is gone each fail here by name. + */ + test("the swept routes are the routes the app has", () => { + expect(PATTERNS.length, "no page.tsx found under src/app").toBeGreaterThan( + 50, + ); + const unresolved = PATTERNS.filter( + (p) => isDynamic(p) && !NOT_SIGNED_IN[p] && !DYNAMIC_ROUTES[p], + ); + expect( + unresolved, + "dynamic route(s) with no entry in DYNAMIC_ROUTES or NOT_SIGNED_IN", + ).toEqual([]); + const stale = [ + ...Object.keys(DYNAMIC_ROUTES), + ...Object.keys(NOT_SIGNED_IN), + ].filter((p) => !PATTERNS.includes(p)); + expect(stale, "entries for routes that no longer exist").toEqual([]); + }); + + for (const pattern of PATTERNS) { + if (NOT_SIGNED_IN[pattern]) continue; + + test(`${pattern} does not scroll sideways at ${WIDTH}px`, async ({ + page, + }) => { + test.slow(); + const paths = isDynamic(pattern) + ? await DYNAMIC_ROUTES[pattern](page.request) + : [pattern]; + expect(paths.length, `${pattern} resolved to no path`).toBeGreaterThan(0); + + await page.setViewportSize({ width: WIDTH, height: 844 }); + for (const path of paths) { + const response = await page.goto(path, { + waitUntil: "domcontentloaded", + }); + expect( + response?.status() ?? 0, + `${path} answered ${response?.status()}`, + ).toBeLessThan(400); + // A redirect away (a module switched off, a missing record) would + // measure some other page and call it this one. + expect(new URL(page.url()).pathname, `${path} redirected`).toBe( + new URL(path, page.url()).pathname, + ); + await settleForOverflowMeasurement(page); + await expectNoHorizontalOverflow(page, `${path} @${WIDTH}px`); + } + }); + } +}); + +/** + * The lab surfaces, asserted at the elements that broke rather than only at + * the page width. The list's name column collapsed to nothing on a phone + * while the fixed columns beside it pushed the row off the card; the values + * table put its edit and delete buttons in a sideways-scrolling strip. + */ +test.describe("the lab surfaces stay readable on a phone", () => { + test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH }); + + test.beforeEach(async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec"); + await page.setViewportSize({ width: WIDTH, height: 844 }); + }); + + test("the lab list shows each marker's name", async ({ page }) => { + await page.goto("/labs", { waitUntil: "domcontentloaded" }); + const names = page.locator('[data-slot="lab-list-analyte"]'); + await expect(names.first()).toBeVisible(); + const widths = await names.evaluateAll((nodes) => + nodes.map((node) => Math.round(node.getBoundingClientRect().width)), + ); + expect(widths.length).toBeGreaterThan(1); + // A readable name, not the 0 px column the fixed columns left it. + for (const width of widths) expect(width).toBeGreaterThan(60); + }); + + test("the values list keeps its actions on screen", async ({ page }) => { + const [path] = await DYNAMIC_ROUTES["/labs/[biomarkerId]/values"]( + page.request, + ); + await page.goto(path, { waitUntil: "domcontentloaded" }); + const list = page.locator('[data-slot="lab-history-mobile-list"]'); + await expect(list).toBeVisible(); + await expect(page.locator('[data-slot="lab-history-table"]')).toBeHidden(); + + const buttons = list.locator("button"); + expect(await buttons.count()).toBeGreaterThan(1); + const outside = await buttons.evaluateAll( + (nodes) => + nodes + .map((node) => node.getBoundingClientRect()) + .filter((r) => r.left < 0 || r.right > window.innerWidth + 1).length, + ); + expect(outside, "a reading's action sits off-screen").toBe(0); + }); +}); diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 550980d24..6f28780bb 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -33,6 +33,8 @@ import { } from "@playwright/test"; import pg from "pg"; +import { seedMobileRoutesRecord } from "./mobile-routes-fixture"; + // Resolve relative to the project root rather than __dirname/import.meta.url — // Playwright's TS loader runs files as CJS without `"type":"module"`, and // loading either dirname helper at module scope crashes the test runner. @@ -191,6 +193,27 @@ export const MODULES_STORAGE_STATE_PATH = resolve( "e2e/setup/storageStateModules.json", ); +/** + * The phone-width route sweep's own account (`mobile-route-overflow.spec.ts`). + * + * It holds one or two of everything a route can render + * (`mobile-routes-fixture.ts`), because the sweep is only worth something + * against populated pages, and writing that much into the shared account would + * change what every other spec reads. An administrator, so the admin console + * is swept too; `FEMALE`, so the cycle surfaces render without a switch. + */ +export const E2E_MOBILE_ROUTES = { + email: "e2e-mobile-routes@healthlog.test", + username: "e2e-mobile-routes", + password: "Wd7!Kp3mTz9qRv2N", + role: "ADMIN", +} as const; + +export const MOBILE_ROUTES_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateMobileRoutes.json", +); + /** * The AI-optional journeys' own account (`ai-optional-*.spec.ts`). * @@ -823,6 +846,43 @@ export default async function globalSetup(config: FullConfig): Promise { ], ); + // The phone-width route sweep's account. Every opt-in module is on, so + // the sweep reaches the glucose, documents, wellbeing, nutrients and + // environment surfaces; its record is written after the login capture. + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + height_cm, date_of_birth, gender, + onboarding_completed_at, onboarding_tour_completed, + module_preferences_json) + VALUES ($1, $2, $3, $4, $5, $6, $6, 168, $7, 'FEMALE', $6, true, + $8::jsonb) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + role = EXCLUDED.role, + updated_at = EXCLUDED.updated_at, + onboarding_completed_at = EXCLUDED.onboarding_completed_at, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, + module_preferences_json = EXCLUDED.module_preferences_json`, + [ + cuid(), + E2E_MOBILE_ROUTES.username, + E2E_MOBILE_ROUTES.email, + await hashPassword(E2E_MOBILE_ROUTES.password), + E2E_MOBILE_ROUTES.role, + now, + new Date("1988-04-12T00:00:00.000Z"), + JSON.stringify({ + glucose: true, + environment: true, + inboundDocuments: true, + mentalHealth: true, + nutrients: true, + }), + ], + ); + // The AI-optional journeys' account. Its provider, stored briefing, // consent and Coach rows are set by `e2e/setup/ai-optional-fixture.ts` // before each journey. @@ -1441,6 +1501,11 @@ export default async function globalSetup(config: FullConfig): Promise { // The module-surfaces journey's jar — see `E2E_MODULES`. await capture(E2E_MODULES, MODULES_STORAGE_STATE_PATH); + // The phone-width route sweep's jar, then its record — see + // `E2E_MOBILE_ROUTES`. + await capture(E2E_MOBILE_ROUTES, MOBILE_ROUTES_STORAGE_STATE_PATH); + await seedMobileRoutesRecord(baseURL, MOBILE_ROUTES_STORAGE_STATE_PATH); + // The AI-optional journeys' jar — see `E2E_AI_OPTIONAL`. await capture(E2E_AI_OPTIONAL, AI_OPTIONAL_STORAGE_STATE_PATH); diff --git a/e2e/setup/mobile-routes-fixture.ts b/e2e/setup/mobile-routes-fixture.ts new file mode 100644 index 000000000..0213adbd5 --- /dev/null +++ b/e2e/setup/mobile-routes-fixture.ts @@ -0,0 +1,204 @@ +/** + * The record `mobile-route-overflow.spec.ts` measures. + * + * A phone-width overflow guard that visits an empty page proves nothing: an + * empty list fits any viewport, and the lab list that panned sideways on a + * phone only did so once it held a reading. So the account gets one or two of + * everything a signed-in route can render — readings with reference ranges, a + * medication, a mood entry, a visit, a vaccination, an illness episode, a + * custom metric, a workout, a document — and every dynamic route has a real id + * to open. + * + * Written through the app's own routes, once, from `globalSetup`, so the + * parallel workers of the spec never race each other to seed it. An account + * that already holds a lab reading is taken as seeded, which keeps a local + * re-run against a reused database from doubling every row. + */ +import { request as playwrightRequest } from "@playwright/test"; + +/** Long on purpose: the names are what a narrow column has to give way to. */ +export const MOBILE_ROUTES_ANALYTE = "Glomeruläre Filtrationsrate (eGFR)"; + +export async function seedMobileRoutesRecord( + baseURL: string, + storageStatePath: string, +): Promise { + const ctx = await playwrightRequest.newContext({ + baseURL, + storageState: storageStatePath, + }); + try { + // The document is the last thing written, so its presence means a + // complete record. A run that died half-way is finished by the next one: + // a row it already wrote answers 409 and is skipped. + const existing = await ctx.get("/api/documents/inbound"); + const existingBody = (await existing.json()) as { + data: { documents?: unknown[] } | unknown[] | null; + }; + const documents = Array.isArray(existingBody.data) + ? existingBody.data + : (existingBody.data?.documents ?? []); + if (documents.length > 0) return; + + const post = async (path: string, data: unknown): Promise => { + const res = await ctx.post(path, { data }); + if (res.status() === 409) return null; + if (!res.ok()) { + throw new Error( + `[mobile-routes-fixture] ${path} answered ${res.status()}: ${(await res.text()).slice(0, 200)}`, + ); + } + return ((await res.json()) as { data: unknown }).data; + }; + const daysAgo = (days: number, hour = 8) => { + const at = new Date(Date.now() - days * 86_400_000); + at.setUTCHours(hour, 0, 0, 0); + return at.toISOString(); + }; + + for (let day = 1; day <= 6; day += 1) { + await post("/api/measurements", { + type: "WEIGHT", + value: 80 + day / 10, + measuredAt: daysAgo(day, 7), + }); + await post("/api/measurements", { + type: "BLOOD_PRESSURE_SYS", + value: 120 + day, + measuredAt: daysAgo(day, 7), + }); + await post("/api/measurements", { + type: "BLOOD_PRESSURE_DIA", + value: 78 + (day % 3), + measuredAt: daysAgo(day, 7), + }); + await post("/api/measurements", { + type: "PULSE", + value: 60 + day, + measuredAt: daysAgo(day, 7), + }); + await post("/api/measurements", { + type: "BLOOD_GLUCOSE", + value: 92 + day, + glucoseContext: "FASTING", + measuredAt: daysAgo(day, 6), + }); + await post("/api/mood-entries", { + mood: day % 2 ? "GUT" : "OKAY", + moodLoggedAt: daysAgo(day, 20), + }); + } + + // Two readings of one marker, so the list row has a trend line and a + // range bar, plus a qualitative one with neither. + for (const [days, value] of [ + [40, 78], + [5, 84], + ] as const) { + await post("/api/labs", { + analyte: MOBILE_ROUTES_ANALYTE, + panel: "Nierenwerte und Elektrolyte", + value, + unit: "mL/min/1.73m²", + referenceLow: 60, + referenceHigh: 120, + takenAt: daysAgo(days), + }); + } + await post("/api/labs", { + analyte: "Hepatitis-B-Oberflächenantigen", + valueText: "negativ", + takenAt: daysAgo(5), + }); + + await post("/api/medications", { + name: "Ramipril", + dose: "5 mg", + schedules: [{ windowStart: "08:00", windowEnd: "09:00" }], + }); + + const practitioner = (await post("/api/practitioners", { + name: "Gemeinschaftspraxis für Allgemeinmedizin", + specialty: "Allgemeinmedizin und Innere Medizin", + })) as { id: string } | null; + await post("/api/encounters", { + occurredAt: daysAgo(20), + practitionerId: practitioner?.id ?? null, + reason: "Routinekontrolle Blutdruck und Blutbild", + }); + await post("/api/vaccinations", { + occurredAt: daysAgo(300), + antigenSlug: "tetanus", + doseNumber: 1, + lotNumber: "AB1234-XY", + practitionerId: practitioner?.id ?? null, + }); + + await post("/api/illness/episodes", { + label: "Erkältung mit Husten", + type: "INFECTION", + onsetAt: daysAgo(12), + resolvedAt: daysAgo(6), + }); + + const metric = (await post("/api/custom-metrics", { + name: "Augeninnendruck rechts", + unit: "mmHg", + targetLow: 10, + targetHigh: 21, + decimals: 0, + })) as { id: string } | null; + const metricId = + metric?.id ?? + ( + (await (await ctx.get("/api/custom-metrics")).json()) as { + data: { customMetrics: Array<{ id: string }> }; + } + ).data.customMetrics[0]?.id; + await post(`/api/custom-metrics/${metricId}/entries`, { + value: 16, + measuredAt: daysAgo(3), + }); + + await post("/api/workouts/batch", { + workouts: [ + { + sportType: "running", + startedAt: daysAgo(2, 6), + endedAt: new Date( + new Date(daysAgo(2, 6)).getTime() + 45 * 60_000, + ).toISOString(), + source: "APPLE_HEALTH", + externalId: "e2e-mobile-routes-run", + activeEnergyKcal: 420, + distanceM: 6500, + }, + ], + }); + + const pdf = + "%PDF-1.4\n%e2e-mobile-routes\n" + + "1 0 obj<>endobj\n" + + "2 0 obj<>endobj\n" + + "3 0 obj<>endobj\n" + + "xref\n0 4\ntrailer<>\n%%EOF\n"; + const doc = await ctx.post("/api/documents/inbound", { + multipart: { + file: { + name: "laborbefund-hausarzt-mit-langem-dateinamen.pdf", + mimeType: "application/pdf", + buffer: Buffer.from(pdf), + }, + title: "Laborbefund Hausarzt mit einem sehr langen Titel", + kind: "LAB_RESULT", + }, + }); + if (!doc.ok()) { + throw new Error( + `[mobile-routes-fixture] /api/documents/inbound answered ${doc.status()}`, + ); + } + } finally { + await ctx.dispose(); + } +} diff --git a/src/__tests__/dense-measurement-read-guard.test.ts b/src/__tests__/dense-measurement-read-guard.test.ts index 8414051a4..35060ade2 100644 --- a/src/__tests__/dense-measurement-read-guard.test.ts +++ b/src/__tests__/dense-measurement-read-guard.test.ts @@ -1,6 +1,5 @@ /** - * Structural guard: background and insight readers must not materialise a - * dense measurement stream. + * Structural guard: no reader materialises a dense measurement stream. * * #1023: a single account syncing heart rate from a watch took the worker * down within half an hour. The status-card read was bounded in time @@ -10,17 +9,34 @@ * SQL (`readDayAggregates`, `readLiveBuckets`), so the reader's size tracks * the number of days, not the sampling rate. * - * What this guard pins, in the directories that run on the worker or feed an - * insight: every `measurement.findMany` without a `take` reads a type that is - * named as a literal and is not one a device streams. A read whose type is a - * variable could be handed a dense type by the next caller, so it must be - * listed below with the reason it is safe. The list is frozen both ways: a new - * unlisted read fails, and an entry whose read is gone fails too. + * The same class kept turning up outside the directories this guard first + * walked: the targets page read a year of glucose and a month of heart rate, + * the cycle insights read a year of every outcome channel, the per-kind series + * read heart-rate variability and blood oxygen raw for ten years, all from + * `app/api` or a helper beside it. So the guard now covers the request + * handlers, import, export, the targets and the doctor report as well, and + * the raw SQL reads of `measurements` anywhere in the tree. * - * It is a tripwire, not a proof: the matcher reads source text, and a read - * written some other way (a raw SELECT of rows, a helper in another - * directory) passes it. The floor below fails the guard if the matcher stops - * finding reads at all. + * Two checks. + * + * 1. `measurement.findMany` without a `take`, in the scoped directories, + * must be bounded by its `where`: a type named as a literal (or a list of + * literals) none of which a device streams, or a key list (`id`, + * `externalId` or `measuredAt` `in: [...]`, or an exact `measuredAt`). + * Anything else is listed below with the reason it stays bounded. + * + * 2. A raw `$queryRaw` / `$queryRawUnsafe` whose statement reads from + * `measurements` must fold: `GROUP BY`, `DISTINCT`, `LIMIT`, or a select + * list of aggregates only. A statement that returns rows is listed below + * with its reason. A statement held in a variable is invisible to the + * matcher; that is the check's known blind spot. + * + * Both lists are frozen both ways: a new unlisted read fails, and an entry + * whose read is gone fails too. The floors below fail the guard if a matcher + * stops finding reads at all, and the "plants" cases run each matcher over a + * planted offender, which is the mutation proof kept in the suite: the + * planted findMany of `BLOOD_GLUCOSE` over a year and the planted raw SELECT of + * every pulse row are both caught. It is a tripwire, not a proof. */ import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; @@ -42,6 +58,12 @@ const SCOPED_DIRS = [ "lib/analytics/", "lib/dashboard/", "lib/daily/", + // Widened in v1.39.7: the request handlers and the helpers they lean on. + "app/api/", + "lib/import", + "lib/export", + "lib/targets/", + "lib/doctor-report", ]; /** Types a device writes at sampling rate rather than a few times a day. */ @@ -55,8 +77,8 @@ const DENSE_TYPES: ReadonlySet = new Set([ ]); /** - * Untaken reads whose type is not a sparse literal, keyed - * `::`, with why each stays bounded. + * Untaken reads whose `where` neither names sparse literal types nor a key + * list, keyed `::`, with why each stays bounded. */ const ALLOWED: Record = { "lib/insights/comprehensive-generate.ts::(shorthand)": @@ -69,8 +91,6 @@ const ALLOWED: Record = { "A constant for six-minute-walk distance, written at most daily.", "lib/insights/derived/wellness-scores.ts::measurementType": "Daily computed / provider scores (recovery, strain, readiness), one row per day.", - "lib/jobs/reaction-line.ts::{ in: [...types]": - "Exact-instant match (`measuredAt: row.occurredAt`), not a window.", "lib/jobs/step-consolidation-repair.ts::STEP_TYPE": "Repair job over one user's step rows for a bounded day range; runs once per repair, not per status refresh.", 'lib/ai/coach/snapshot.ts::"BLOOD_GLUCOSE" as never': @@ -79,6 +99,34 @@ const ALLOWED: Record = { "Same 30-day clinical glucose panel as the coach snapshot (per-reading metrics), grouped by meal context.", "lib/analytics/score/reader.ts::{ in: types": "Health-score inputs: steps (drained to one row per day nightly), sleep, waist, weight, blood pressure, fasting glucose only.", + 'app/api/analytics/route.ts::"BLOOD_GLUCOSE"': + "The 30-day per-context glucose summary, the same window and bound as the dashboard and coach glucose panels: at a sensor's fixed rate, below 45 000 rows.", + "app/api/measurements/series/route.ts::(shorthand)": + "The raw branch of the per-kind series. Every dense kind reaches it only inside 90 days and under 10 000 rows (counted first); the sparse kinds are a few readings a day.", + "app/api/measurements/batch/route.ts::p.row.type as MeasurementType": + "Cross-source merge probe: one ±tolerance window around each posted reading's instant, so the rows found are bounded by the batch size.", + "app/api/measurements/route.ts::(shorthand)": + "Cross-source merge probe for one posted sleep segment: SLEEP_DURATION in a ±tolerance window around its end.", + "lib/targets/build-response.ts::{ in: recentTypes": + "Thirty days of weight, blood pressure, resting heart rate, body fat and steps (drained to one row per day). PULSE is filtered out of `recentTypes` and folds in SQL.", + "lib/doctor-report/collect.ts::{ notIn: rawExcluded": + "The report window's sparse types. Pulse, heart-rate variability and blood oxygen over 10 000 readings or any window over 90 days, and glucose over 90 days, are in `rawExcluded` and read as day buckets; glucose inside 90 days stays raw for the clinical panel (per-reading metrics), below 26 000 rows at a sensor's rate.", +}; + +/** + * Raw statements that read rows of `measurements` without folding them, keyed + * `::`, with why each + * stays bounded. + */ +const ALLOWED_SQL: Record = { + "lib/analytics/bp-in-target-fast-path.ts::1": + "Canonical-source blood pressure, one type over 365 days: a cuff, a few readings a day. The in-target pairing needs the individual readings.", + "lib/insights/comprehensive-aggregator.ts::1": + "Canonical-source blood pressure and weight over 90 days: sparse types, a few rows a day.", + "lib/measurements/reconcile-external-measurement.ts::1": + "Row locks (`FOR UPDATE`) on an id list the caller already read; returns those ids only.", + "lib/rollups/measurement-coverage.ts::1": + "Selects from `unnest` of the caller's type list; `measurements` is read inside `EXISTS`, one row per type at most.", }; interface UntakenRead { @@ -87,63 +135,199 @@ interface UntakenRead { typeExpr: string; } -function scopedFiles(): string[] { +function lineOf(src: string, index: number): number { + return src.slice(0, index).split("\n").length; +} + +/** Index just past the bracket that closes the one at `open`. */ +function closeOf(src: string, open: number): number { + const pair: Record = { "(": ")", "{": "}", "[": "]" }; + const want = pair[src[open]]; + let depth = 1; + let i = open + 1; + while (depth > 0 && i < src.length) { + if (src[i] === src[open]) depth += 1; + else if (src[i] === want) depth -= 1; + i += 1; + } + return i; +} + +/** The `where: { … }` object of a call, or null when `where` is a variable. */ +function whereObject(call: string): string | null { + const m = /\bwhere\s*:\s*\{/.exec(call); + if (!m) return null; + const open = m.index + m[0].length - 1; + return call.slice(open, closeOf(call, open)); +} + +/** The type expression a `where` object constrains on. */ +function typeExprOf(call: string): string { + const where = whereObject(call); + if (where === null) { + return /\bwhere\s*[,}]/.test(call) ? "(where variable)" : "(none)"; + } + const typed = /\btype\s*:\s*([^,\n}]+)/.exec(where); + if (typed) return typed[1].trim(); + return /\btype\s*[,}\n]/.test(where) ? "(shorthand)" : "(none)"; +} + +/** A `where` that pins rows by key rather than by a window. */ +function keyBounded(call: string): boolean { + const where = whereObject(call) ?? ""; + return ( + /\b(?:id|externalId|measuredAt)\s*:\s*\{\s*in\s*:/.test(where) || + /\bmeasuredAt\s*:\s*(?![\s{])/.test(where) + ); +} + +function sparseLiteral(typeExpr: string): boolean { + const lit = /^"([A-Z_0-9]+)"/.exec(typeExpr); + if (lit) return !DENSE_TYPES.has(lit[1]); + const list = /^\{\s*in\s*:\s*\[([^\]]*)/.exec(typeExpr); + if (!list) return false; + const items = list[1] + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + return ( + items.length > 0 && + items.every((item) => { + const m = /^"([A-Z_0-9]+)"$/.exec(item); + return m !== null && !DENSE_TYPES.has(m[1]); + }) + ); +} + +/** Every untaken `measurement.findMany` in `src`, with its type expression. */ +function untakenFindManyReads( + file: string, + src: string, +): { all: number; untaken: UntakenRead[] } { + let all = 0; + const untaken: UntakenRead[] = []; + // Whitespace-tolerant: a call split across lines still matches. + const re = /\.measurement\s*\.\s*findMany\s*\(/g; + for (let m = re.exec(src); m; m = re.exec(src)) { + all += 1; + const call = src.slice(m.index, closeOf(src, m.index + m[0].length - 1)); + if (/\btake\b/.test(call)) continue; + if (keyBounded(call)) continue; + // A type expression can itself contain a full bracket (`{ in: [...] }`); + // the regex above stops at the first `,` / `}` / newline, which is the + // stable key the allowlist uses. + untaken.push({ + file, + line: lineOf(src, m.index), + typeExpr: typeExprOf(call), + }); + } + return { all, untaken }; +} + +/** The statement text of the raw call starting at `at`, or null if opaque. */ +function statementAt(src: string, at: number): string | null { + let i = at; + // Skip a type argument: `$queryRaw>`. + if (src[i] === "<") { + let depth = 1; + i += 1; + while (depth > 0 && i < src.length) { + if (src[i] === "<") depth += 1; + else if (src[i] === ">") depth -= 1; + i += 1; + } + } + while (/\s/.test(src[i] ?? "")) i += 1; + if (src[i] === "`") { + // Tagged template: up to the matching backtick, skipping `${ … }`. + let j = i + 1; + while (j < src.length && src[j] !== "`") { + if (src[j] === "$" && src[j + 1] === "{") j = closeOf(src, j + 1); + else j += 1; + } + return src.slice(i, j + 1); + } + if (src[i] === "(") return src.slice(i, closeOf(src, i)); + return null; +} + +const READS_MEASUREMENTS = + /\bFROM\s+"?measurements"?(?![_\w])|canonicalMeasurementsFrom\s*\(/i; +const FOLDS = + /GROUP\s+BY|\bDISTINCT\b|\bLIMIT\b|^[\s`(]*(?:Prisma\.sql`)?\s*SELECT\s+(?:COUNT|SUM|AVG|MIN|MAX|STDDEV_POP)\s*\(/i; + +interface RawRead { + file: string; + line: number; + folds: boolean; +} + +/** Every raw statement in `src` that reads from `measurements`. */ +function rawMeasurementReads(file: string, src: string): RawRead[] { + const out: RawRead[] = []; + const re = /\.\$queryRaw(?:Unsafe)?\b/g; + for (let m = re.exec(src); m; m = re.exec(src)) { + const statement = statementAt(src, m.index + m[0].length); + if (statement === null || !READS_MEASUREMENTS.test(statement)) continue; + out.push({ + file, + line: lineOf(src, m.index), + folds: FOLDS.test(statement), + }); + } + return out; +} + +function sourceFiles(): string[] { return walkSourceFiles(SRC, { floor: 3000 }) - .filter((p) => SCOPED_DIRS.some((d) => p.startsWith(d))) + .filter((p) => !p.startsWith("generated/")) .filter((p) => !p.includes("__tests__")) .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) .sort(); } -function untakenReads(): { all: number; untaken: UntakenRead[] } { +function scan() { let all = 0; const untaken: UntakenRead[] = []; - for (const file of scopedFiles()) { + const raw: RawRead[] = []; + for (const file of sourceFiles()) { const src = readFileSync(join(SRC, file), "utf8"); - // Whitespace-tolerant: a call split across lines still matches. - const re = /\.measurement\s*\.\s*findMany\s*\(/g; - for (let m = re.exec(src); m; m = re.exec(src)) { - all += 1; - let i = m.index + m[0].length; - let depth = 1; - while (depth > 0 && i < src.length) { - if (src[i] === "(") depth += 1; - else if (src[i] === ")") depth -= 1; - i += 1; - } - const call = src.slice(m.index, i); - if (/\btake\b/.test(call)) continue; - const typed = /\btype\s*:\s*([^,\n}]+)/.exec(call); - const typeExpr = typed - ? typed[1].trim() - : /\btype\s*,/.test(call) - ? "(shorthand)" - : "(none)"; - untaken.push({ - file, - line: src.slice(0, m.index).split("\n").length, - typeExpr, - }); - } + raw.push(...rawMeasurementReads(file, src)); + if (!SCOPED_DIRS.some((d) => file.startsWith(d))) continue; + const reads = untakenFindManyReads(file, src); + all += reads.all; + untaken.push(...reads.untaken); } - return { all, untaken }; + return { all, untaken, raw }; } -function sparseLiteral(typeExpr: string): boolean { - const lit = /^"([A-Z_0-9]+)"/.exec(typeExpr); - return lit !== null && !DENSE_TYPES.has(lit[1]); +/** `::` for the n-th unfolded statement of each file. */ +function unfoldedKeys(raw: readonly RawRead[]): Map { + const keys = new Map(); + const perFile = new Map(); + for (const read of raw) { + if (read.folds) continue; + const n = (perFile.get(read.file) ?? 0) + 1; + perFile.set(read.file, n); + keys.set(`${read.file}::${n}`, read); + } + return keys; } describe("dense measurement reads stay bounded (#1023)", () => { - const { all, untaken } = untakenReads(); + const { all, untaken, raw } = scan(); it("finds the reads it is meant to police", () => { // Guard against a matcher that silently matches nothing. - expect(all).toBeGreaterThanOrEqual(40); - expect(untaken.length).toBeGreaterThanOrEqual(15); + // Pinned below the counts on 2026-10-01 (82 / 45 / 37 / 33). + expect(all).toBeGreaterThanOrEqual(75); + expect(untaken.length).toBeGreaterThanOrEqual(40); + expect(raw.length).toBeGreaterThanOrEqual(33); + expect(raw.filter((r) => r.folds).length).toBeGreaterThanOrEqual(30); }); - it("every untaken read names a sparse literal type or is listed with its reason", () => { + it("every untaken read names sparse literal types, a key list, or is listed with its reason", () => { const offenders = untaken .filter((r) => !sparseLiteral(r.typeExpr)) .filter((r) => !(`${r.file}::${r.typeExpr}` in ALLOWED)) @@ -156,20 +340,78 @@ describe("dense measurement reads stay bounded (#1023)", () => { expect(Object.keys(ALLOWED).filter((k) => !present.has(k))).toEqual([]); }); + it("every raw read of measurements folds or is listed with its reason", () => { + const keys = unfoldedKeys(raw); + const offenders = [...keys] + .filter(([key]) => !(key in ALLOWED_SQL)) + .map(([key, read]) => `${key} (line ${read.line})`); + expect(offenders).toEqual([]); + expect(Object.keys(ALLOWED_SQL).filter((k) => !keys.has(k))).toEqual([]); + }); + it("the readers fixed for #1023 fold in SQL rather than reading rows", () => { for (const file of [ "lib/insights/graded-series.ts", "lib/rollups/tiered-context.ts", "lib/jobs/coach-plan-review.ts", "lib/measurements/daily-series-read.ts", + "lib/insights/derived/baseline.ts", + "lib/targets/glucose-read.ts", ]) { const src = readFileSync(join(SRC, file), "utf8"); expect(src, file).not.toMatch(/\.measurement\s*\.\s*findMany\s*\(/); } - const baseline = readFileSync( - join(SRC, "lib/insights/derived/baseline.ts"), - "utf8", + }); + + it("plants: an unbounded findMany of a dense type is caught", () => { + const planted = ` + const rows = await prisma.measurement.findMany({ + where: { userId, type: "BLOOD_GLUCOSE", measuredAt: { gte: oneYearAgo } }, + select: { value: true, measuredAt: true }, + });`; + const { all: n, untaken: found } = untakenFindManyReads( + "app/api/planted/route.ts", + planted, ); - expect(baseline).not.toMatch(/\.measurement\s*\.\s*findMany\s*\(/); + expect(n).toBe(1); + expect(found.map((r) => r.typeExpr)).toEqual(['"BLOOD_GLUCOSE"']); + expect(sparseLiteral(found[0].typeExpr)).toBe(false); + // The same read with a key list or a take is bounded. + expect( + untakenFindManyReads( + "x.ts", + planted.replace("measuredAt: { gte", "id: { in: ids }, x: { gte"), + ).untaken, + ).toEqual([]); + expect( + untakenFindManyReads( + "x.ts", + planted.replace("select:", "take: 10, select:"), + ).untaken, + ).toEqual([]); + // A list of sparse literals passes; one dense member does not. + expect(sparseLiteral('{ in: ["WEIGHT", "BODY_FAT"]')).toBe(true); + expect(sparseLiteral('{ in: ["WEIGHT", "PULSE"]')).toBe(false); + }); + + it("plants: a raw SELECT of rows from measurements is caught", () => { + const planted = + "const rows = await prisma.$queryRaw>`\n" + + ' SELECT m."value" FROM measurements m\n' + + ' WHERE m."user_id" = ${userId} AND m."type" = \'PULSE\'\n' + + "`;"; + const reads = rawMeasurementReads("lib/planted.ts", planted); + expect(reads).toHaveLength(1); + expect(reads[0].folds).toBe(false); + const folded = rawMeasurementReads( + "lib/planted.ts", + planted.replace("WHERE", "WHERE true GROUP BY 1 HAVING true AND"), + ); + expect(folded[0].folds).toBe(true); + const aggregate = rawMeasurementReads( + "lib/planted.ts", + planted.replace('SELECT m."value"', 'SELECT COUNT(*), AVG(m."value")'), + ); + expect(aggregate[0].folds).toBe(true); }); }); diff --git a/src/__tests__/job-queue-runtime-guard.test.ts b/src/__tests__/job-queue-runtime-guard.test.ts new file mode 100644 index 000000000..5110a5c93 --- /dev/null +++ b/src/__tests__/job-queue-runtime-guard.test.ts @@ -0,0 +1,371 @@ +/** + * Structural guard: every pg-boss queue declares how long its job can run, and + * a long one is safe against pg-boss's expiry. + * + * pg-boss gives a job fifteen minutes unless the send says otherwise, then + * fails it, fires its abort signal and retries it while the handler keeps + * going. The full-history imports ran beside themselves that way (#1066), and + * so could every other job that legitimately needs longer: the nightly status + * passes, the pre-generation, the record scan, the key rotation, the backups. + * + * `src/lib/jobs/queue-runtime.ts` declares every queue `short`, `budgeted` or + * `long`. This guard holds the tree to it: + * + * - every queue bound with `createAndWork` is in the table, and every table + * entry is bound; + * - every `boss.send` and every schedule tuple names a queue in the table; + * - a `long` queue has an expiry between the default and pg-boss's 24-hour + * ceiling, every send and schedule for it carries `expireInSeconds` (or an + * identifier the table names as its carrier), it stops on the job's budget + * where the table says (`jobBudget` / `jobDeadline`, or a declared + * one-shot), and it is exclusive per identity where the table says + * (`lockedPass` at the binding, or the named marker); + * - a `budgeted` queue stops on the budget and relies on the default expiry; + * - a `short` queue's sends set no expiry of their own, which would mean the + * job is longer than the table claims. + * + * Floors fail the guard if a matcher stops matching, and the "plants" cases + * run the checks over a planted long queue that lacks each protection in turn + * (the mutation proof kept in the suite). Removing `lockedPass` from the + * pr-detection binding, or the expiry from its cron tuple, fails the real + * checks below. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; +import { + DEFAULT_JOB_EXPIRE_SECONDS, + QUEUE_RUNTIME, + type QueueRuntime, + type SourceSite, +} from "@/lib/jobs/queue-runtime"; + +const SRC = join(process.cwd(), "src"); +const REGISTRARS = [ + "lib/jobs/reminder/register-integration-sync.ts", + "lib/jobs/reminder/register-maintenance.ts", + "lib/jobs/reminder/register-reminders.ts", + "lib/jobs/reminder/register-rollup.ts", + "lib/jobs/reminder/register-status.ts", +]; +/** pg-boss refuses an expiry of 24 hours or more. */ +const MAX_EXPIRE_SECONDS = 24 * 60 * 60; + +const files = walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => !p.startsWith("generated/")) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")); +const source = new Map( + files.map((f) => [f, readFileSync(join(SRC, f), "utf8")]), +); + +/** Strip comments so documentation that quotes code never matches. */ +function code(text: string): string { + return text + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:])\/\/.*$/gm, "$1"); +} + +/** Index just past the bracket that closes the one at `open`. */ +function closeOf(text: string, open: number): number { + const pair: Record = { "(": ")", "[": "]", "{": "}" }; + const want = pair[text[open]]; + let depth = 1; + let i = open + 1; + while (depth > 0 && i < text.length) { + if (text[i] === text[open]) depth += 1; + else if (text[i] === want) depth -= 1; + i += 1; + } + return i; +} + +type Definitions = Map>; + +/** `const NAME = "queue-name"` definitions, per file. */ +function definitionsOf(texts: ReadonlyMap): Definitions { + const definitions: Definitions = new Map(); + for (const [file, text] of texts) { + for (const m of text.matchAll( + /\bconst\s+([A-Z][A-Z0-9_]*)\s*(?::\s*string\s*)?=\s*"([a-z0-9-]+)"/g, + )) { + const list = definitions.get(m[1]) ?? []; + list.push({ file, name: m[2] }); + definitions.set(m[1], list); + } + } + return definitions; +} + +/** Resolve a queue identifier as seen from `file`, preferring its own. */ +function resolve( + definitions: Definitions, + file: string, + ident: string, +): string | null { + const defs = definitions.get(ident) ?? []; + const own = defs.find((d) => d.file === file); + if (own) return own.name; + const names = new Set(defs.map((d) => d.name)); + return names.size === 1 ? [...names][0] : null; +} + +interface Site { + file: string; + queue: string; + text: string; +} + +/** Every `createAndWork(boss, QUEUE, …)` in the registrars. */ +function bindings( + texts: ReadonlyMap, + definitions: Definitions, +): Site[] { + const out: Site[] = []; + for (const file of REGISTRARS) { + const text = code(texts.get(file) ?? ""); + for (const m of text.matchAll( + /createAndWork\s*(?:<[^(]*?>)?\(\s*boss\s*,\s*([A-Z][A-Z0-9_]*)/g, + )) { + const open = text.indexOf("(", m.index); + const queue = resolve(definitions, file, m[1]); + if (queue) { + out.push({ file, queue, text: text.slice(open, closeOf(text, open)) }); + } + } + } + return out; +} + +/** Every `boss.send(QUEUE, …)` / `getGlobalBoss()?.send(QUEUE, …)`. */ +function sends( + texts: ReadonlyMap, + definitions: Definitions, +): Site[] { + const out: Site[] = []; + for (const [file, raw] of texts) { + const text = code(raw); + for (const m of text.matchAll( + /\b(?:boss|getGlobalBoss\(\)\??)\s*\.\s*send\s*(?:<[^(]*?>)?\(\s*([A-Z][A-Z0-9_]*)/g, + )) { + const open = text.indexOf("(", text.indexOf("send", m.index)); + const queue = resolve(definitions, file, m[1]); + if (queue) { + out.push({ file, queue, text: text.slice(open, closeOf(text, open)) }); + } + } + } + return out; +} + +/** Every `[QUEUE, CRON, options?]` tuple in a registrar's `schedules`. */ +function schedules( + texts: ReadonlyMap, + definitions: Definitions, +): Site[] { + const out: Site[] = []; + for (const file of REGISTRARS) { + const text = code(texts.get(file) ?? ""); + const m = /const\s+schedules\s*:\s*ScheduleEntry\[\]\s*=\s*\[/.exec(text); + if (!m) continue; + const open = m.index + m[0].length - 1; + const body = text.slice(open + 1, closeOf(text, open) - 1); + for (let i = 0; i < body.length; i += 1) { + if (body[i] !== "[") continue; + const end = closeOf(body, i); + const tuple = body.slice(i, end); + const ident = /^\[\s*([A-Z][A-Z0-9_]*)/.exec(tuple)?.[1]; + const queue = ident ? resolve(definitions, file, ident) : null; + if (queue) out.push({ file, queue, text: tuple }); + i = end - 1; + } + } + return out; +} + +/** The text of `fn` (a function or a const) in `file`. */ +function bodyOf( + texts: ReadonlyMap, + site: { file: string; fn: string }, +): string { + const text = code(texts.get(site.file) ?? ""); + const m = new RegExp( + `(?:function\\s+${site.fn}\\b|const\\s+${site.fn}\\b)`, + ).exec(text); + if (!m) return ""; + const end = text.indexOf("\n}", m.index); + return text.slice(m.index, end < 0 ? undefined : end + 2); +} + +function siteText( + texts: ReadonlyMap, + site: SourceSite, + binding: Site | undefined, +): string { + return site === "binding" ? (binding?.text ?? "") : bodyOf(texts, site); +} + +const STOPS = /\bjob(?:Budget|Deadline)\s*\(/; + +/** Every finding for one queue's declaration against the tree. */ +function check( + queue: string, + entry: QueueRuntime, + texts: ReadonlyMap, + all: { bindings: Site[]; sends: Site[]; schedules: Site[] }, +): string[] { + const problems: string[] = []; + const binding = all.bindings.find((b) => b.queue === queue); + const carriers = [...all.sends, ...all.schedules].filter( + (s) => s.queue === queue, + ); + if (entry.runtime === "long") { + if ( + !(entry.expireInSeconds > DEFAULT_JOB_EXPIRE_SECONDS) || + !(entry.expireInSeconds < MAX_EXPIRE_SECONDS) + ) { + problems.push(`${queue}: expiry ${entry.expireInSeconds}s out of range`); + } + for (const site of carriers) { + const carried = + /\bexpireInSeconds\b/.test(site.text) || + entry.expiryVia.some((v) => new RegExp(`\\b${v}\\b`).test(site.text)); + if (!carried) { + problems.push( + `${queue}: ${site.file} sends or schedules it without an expiry`, + ); + } + } + if (typeof entry.stop === "object" && "oneShot" in entry.stop) { + if (entry.stop.oneShot.length < 20) { + problems.push(`${queue}: a one-shot needs its reason`); + } + } else if (!STOPS.test(siteText(texts, entry.stop, binding))) { + problems.push(`${queue}: does not stop on the job's budget`); + } + if (entry.exclusive === "lockedPass") { + if (!/\blockedPass\s*\(/.test(binding?.text ?? "")) { + problems.push(`${queue}: binding holds no lockedPass`); + } + } else if ( + !siteText(texts, entry.exclusive.at, binding).includes( + entry.exclusive.marker, + ) + ) { + problems.push(`${queue}: no "${entry.exclusive.marker}" where declared`); + } + } else { + if (entry.runtime === "budgeted") { + if (!STOPS.test(siteText(texts, entry.budget, binding))) { + problems.push(`${queue}: does not stop on the job's budget`); + } + } + for (const site of carriers) { + if (/\bexpireInSeconds\b/.test(site.text)) { + problems.push( + `${queue}: ${site.file} sets an expiry on a queue declared ${entry.runtime}`, + ); + } + } + } + return problems; +} + +function scan(texts: ReadonlyMap) { + const definitions = definitionsOf(texts); + return { + bindings: bindings(texts, definitions), + sends: sends(texts, definitions), + schedules: schedules(texts, definitions), + }; +} + +describe("every queue declares its runtime", () => { + const all = scan(source); + const bound = new Set(all.bindings.map((b) => b.queue)); + + it("finds the bindings, sends and schedules it is meant to police", () => { + // Pinned below the counts on 2026-10-01 (112 / 64 / 74). + expect(all.bindings.length).toBeGreaterThanOrEqual(105); + expect(all.sends.length).toBeGreaterThanOrEqual(50); + expect(all.schedules.length).toBeGreaterThanOrEqual(70); + expect( + Object.values(QUEUE_RUNTIME).filter((e) => e.runtime === "long").length, + ).toBeGreaterThanOrEqual(15); + }); + + it("every bound queue is in the table, and every entry is bound", () => { + expect([...bound].filter((q) => !(q in QUEUE_RUNTIME)).sort()).toEqual([]); + expect( + Object.keys(QUEUE_RUNTIME) + .filter((q) => !bound.has(q)) + .sort(), + ).toEqual([]); + }); + + it("every send and schedule names a queue in the table", () => { + const unknown = [...all.sends, ...all.schedules] + .filter((s) => !(s.queue in QUEUE_RUNTIME)) + .map((s) => `${s.file} → ${s.queue}`); + expect(unknown).toEqual([]); + }); + + it("every queue meets its declaration", () => { + const problems = Object.entries(QUEUE_RUNTIME).flatMap(([queue, entry]) => + check(queue, entry, source, all), + ); + expect(problems).toEqual([]); + }); + + it("plants: a long queue missing any protection is caught", () => { + const registrar = REGISTRARS[1]; + const planted = (binding: string, schedule: string) => + new Map([ + ...source, + [ + registrar, + `const PLANT_QUEUE = "planted-long";\n` + + `const schedules: ScheduleEntry[] = [\n ${schedule},\n];\n` + + `async function register(boss) {\n await createAndWork(boss, PLANT_QUEUE, { localConcurrency: 1 }, ${binding});\n}\n`, + ], + ]); + const entry: QueueRuntime = { + runtime: "long", + expireInSeconds: 3600, + expiryVia: [], + stop: "binding", + exclusive: "lockedPass", + why: "planted", + }; + const findings = (binding: string, schedule: string) => { + const texts = planted(binding, schedule); + return check("planted-long", entry, texts, scan(texts)); + }; + const safe = [ + "lockedPass(PLANT_QUEUE, () => WHOLE_PASS, async (jobs) => run(jobBudget(jobs)))", + "[PLANT_QUEUE, CRON, { expireInSeconds: 3600 }]", + ] as const; + expect(findings(...safe)).toEqual([]); + expect(findings(safe[0], "[PLANT_QUEUE, CRON]")).toEqual([ + `planted-long: ${registrar} sends or schedules it without an expiry`, + ]); + expect( + findings("lockedPass(PLANT_QUEUE, () => WHOLE_PASS, run)", safe[1]), + ).toEqual(["planted-long: does not stop on the job's budget"]); + expect(findings("async (jobs) => run(jobBudget(jobs))", safe[1])).toEqual([ + "planted-long: binding holds no lockedPass", + ]); + expect( + check( + "planted-long", + { ...entry, expireInSeconds: 900 }, + ...((): [ReadonlyMap, ReturnType] => { + const texts = planted(...safe); + return [texts, scan(texts)]; + })(), + ), + ).toEqual(["planted-long: expiry 900s out of range"]); + }); +}); diff --git a/src/app/api/admin/encryption/rotate/route.ts b/src/app/api/admin/encryption/rotate/route.ts index 0beacfe7f..e3f8dc063 100644 --- a/src/app/api/admin/encryption/rotate/route.ts +++ b/src/app/api/admin/encryption/rotate/route.ts @@ -33,6 +33,7 @@ import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { ENCRYPTION_KEY_ROTATE_QUEUE, ENCRYPTION_KEY_ROTATE_SINGLETON, + ENCRYPTION_KEY_ROTATE_EXPIRE_SECONDS, } from "@/lib/jobs/encryption-key-rotate"; import { getActiveKeyId } from "@/lib/crypto"; import { NextRequest } from "next/server"; @@ -57,6 +58,7 @@ export const POST = apiHandler(async (request: NextRequest) => { singletonKey: ENCRYPTION_KEY_ROTATE_SINGLETON, retryLimit: 2, retryDelay: 60, + expireInSeconds: ENCRYPTION_KEY_ROTATE_EXPIRE_SECONDS, }, ); diff --git a/src/app/api/cycle/insights/__tests__/route-locale.test.ts b/src/app/api/cycle/insights/__tests__/route-locale.test.ts index a55be82e6..0a516735c 100644 --- a/src/app/api/cycle/insights/__tests__/route-locale.test.ts +++ b/src/app/api/cycle/insights/__tests__/route-locale.test.ts @@ -37,6 +37,9 @@ vi.mock("@/lib/cycle/phase-crosstab", async (importOriginal) => ({ discoverPhaseCorrelations(args), })); +vi.mock("@/lib/measurements/day-aggregates", () => ({ + readSourceDayAggregates: vi.fn().mockResolvedValue([]), +})); vi.mock("@/lib/db", () => ({ prisma: { auditLog: { create: vi.fn() }, diff --git a/src/app/api/cycle/insights/route.ts b/src/app/api/cycle/insights/route.ts index 4fc89c5b3..fb9d6c1c8 100644 --- a/src/app/api/cycle/insights/route.ts +++ b/src/app/api/cycle/insights/route.ts @@ -43,6 +43,7 @@ import { type SymptomDay, } from "@/lib/cycle/symptom-phase"; import { addDays } from "@/lib/cycle/day-math"; +import { readSourceDayAggregates } from "@/lib/measurements/day-aggregates"; import { moodDateKey } from "@/lib/mood/date-key"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; @@ -130,27 +131,19 @@ export const GET = apiHandler(async () => { orderBy: { measuredAt: "asc" }, select: { measuredAt: true, value: true }, }), - // The outcome metrics the phase contrast compares — soft-delete-scoped, - // canonical-source deduped per day inside `metricDayMap`. Skipped whole - // for a caller outside `measurements`. + // The outcome metrics the phase contrast compares, soft-delete-scoped. + // A year of steps, heart-rate variability and sensor glucose is far too + // many readings to hold as objects (#1023), and the crosstab uses only + // each day's sum and count after picking one source and device per day. + // So the fold runs in SQL, one row per type, local day, source and + // device, the read the coach's cycle snapshot already makes. Skipped + // whole for a caller outside `measurements`. seesMeasurements - ? prisma.measurement.findMany({ - where: { - userId: user.id, - deletedAt: null, - type: { in: PHASE_CROSSTAB_METRIC_TYPES }, - measuredAt: { - gte: new Date(Date.parse(`${from}T00:00:00Z`)), - }, - }, - orderBy: { measuredAt: "asc" }, - select: { - type: true, - value: true, - measuredAt: true, - source: true, - deviceType: true, - }, + ? readSourceDayAggregates({ + userId: user.id, + types: PHASE_CROSSTAB_METRIC_TYPES, + since: new Date(Date.parse(`${from}T00:00:00Z`)), + timeZone: tz, }) : [], prisma.user.findUnique({ @@ -200,10 +193,13 @@ export const GET = apiHandler(async () => { today, ); + // Each row stands for its day's readings: `value` is their sum and + // `count` their number, so `metricDayMap` gets the raw readings' day mean. const measurements: CrossMetricMeasurement[] = measurementRows.map((m) => ({ type: m.type, - value: m.value, - measuredAt: m.measuredAt, + value: m.sum, + count: m.n, + measuredAt: m.firstAt, source: m.source, deviceType: m.deviceType, })); diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index 29d5fe949..87cae0237 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -19,8 +19,12 @@ import { unzipSync } from "fflate"; vi.mock("@/lib/db", () => ({ prisma: { - // `count` sizes the report's pulse window (#1023): a sparse account. - measurement: { findMany: vi.fn(), count: vi.fn(async () => 0) }, + // `groupBy` sizes the report's dense windows (#1023): a sparse account. + measurement: { + findMany: vi.fn(), + count: vi.fn(async () => 0), + groupBy: vi.fn(async () => []), + }, medication: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, diff --git a/src/app/api/insights/targets/__tests__/route.test.ts b/src/app/api/insights/targets/__tests__/route.test.ts index 19b0ca3e4..3fe45c806 100644 --- a/src/app/api/insights/targets/__tests__/route.test.ts +++ b/src/app/api/insights/targets/__tests__/route.test.ts @@ -30,6 +30,16 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; +// The glucose and heart-rate streams fold in SQL; this file pins the route's +// composition over Prisma mocks, so both readers are mocked at the module. +vi.mock("@/lib/targets/glucose-read", async (importOriginal) => ({ + ...(await importOriginal()), + readGlucoseTargetSummaries: vi.fn(), +})); +vi.mock("@/lib/analytics/resting-pulse-read", () => ({ + readRestingPulseProxy: vi.fn(), +})); + vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn() }, @@ -84,6 +94,11 @@ import { GET } from "../route"; import { getSession } from "@/lib/auth/session"; import { prisma } from "@/lib/db"; import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; +import { + foldGlucoseTargetRows, + readGlucoseTargetSummaries, +} from "@/lib/targets/glucose-read"; +import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read"; const SESSION_USER = { id: "user-targets-1", @@ -153,6 +168,8 @@ beforeEach(() => { // v1.28.25 — the latest-ever-per-type read is a raw `DISTINCT ON` // (Prisma's `distinct` dedups client-side after pulling every row). (prisma.$queryRawUnsafe as ReturnType).mockResolvedValue([]); + vi.mocked(readGlucoseTargetSummaries).mockResolvedValue([]); + vi.mocked(readRestingPulseProxy).mockResolvedValue([]); }); describe("GET /api/insights/targets — mood-rollup tier swap", () => { @@ -338,16 +355,26 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => { it("bounds the glucose read with the same one-year floor (v1.16.8)", async () => { // The glucose section used to scan the user's entire BLOOD_GLUCOSE // history on every cold build (no `measuredAt` filter at all) just - // to read the latest value per context. The read now carries the - // same 365-day floor as the latest-ever-per-type query. + // to read the latest value per context. It now folds in SQL, newest + // per context over a year and per day over thirty days, and no glucose + // or heart-rate reading is read as a row. await callGet(makeReq()); const calls = ( prisma.measurement.findMany as ReturnType ).mock.calls.map((c) => c[0]); - const glucoseCall = calls.find((c) => c?.where?.type === "BLOOD_GLUCOSE"); - expect(glucoseCall).toBeDefined(); - expect(glucoseCall?.where?.measuredAt?.gte).toBeInstanceOf(Date); + expect( + calls.find((c) => c?.where?.type === "BLOOD_GLUCOSE"), + ).toBeUndefined(); + expect( + calls.filter((c) => c?.where?.type?.in?.includes?.("PULSE")), + ).toEqual([]); + const [args] = vi.mocked(readGlucoseTargetSummaries).mock.calls[0]; + const day = 86_400_000; + expect(Date.now() - args.since.getTime()).toBeGreaterThan(364 * day); + expect(Date.now() - args.since.getTime()).toBeLessThan(366 * day); + expect(Date.now() - args.recentSince.getTime()).toBeGreaterThan(29 * day); + expect(Date.now() - args.recentSince.getTime()).toBeLessThan(31 * day); }); it("pins the complete ordered public response for representative populated data", async () => { @@ -408,10 +435,20 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => { ).mockImplementation( async (args: { where?: { type?: string | { in: string[] } } }) => { if (args.where?.type === "SLEEP_DURATION") return sleepRows; - if (args.where?.type === "BLOOD_GLUCOSE") return glucoseRows; return genericRows; }, ); + vi.mocked(readGlucoseTargetSummaries).mockImplementation( + async ({ recentSince, timeZone }) => + foldGlucoseTargetRows( + glucoseRows.map((row) => ({ + ...row, + glucoseContext: row.glucoseContext as "FASTING", + })), + recentSince, + timeZone, + ), + ); (prisma.$queryRawUnsafe as ReturnType).mockResolvedValue([ { type: "WEIGHT", value: 70 }, { type: "BLOOD_PRESSURE_SYS", value: 125 }, diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index ffcaafece..278ddb899 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -543,9 +543,18 @@ async function sleepListResponse( loadUserSourcePriority(user.id), ]); - // Bound the read window: honour an explicit from/to, otherwise cap to - // the last year so the per-stage scan stays small. - const since = from ?? new Date(Date.now() - SLEEP_LIST_MAX_DAYS * 86_400_000); + // Bound the read window to a year ending at `to` (or now). An explicit + // `from` narrows it but never widens it: an early `from` used to walk every + // per-stage row back to that date, which on a multi-year, multi-source + // account is the unbounded read this cap exists to stop. Older nights are + // reached by moving `to` back, and `meta.windowFrom` says where a cut + // window started so a client can tell a cut from an empty history. + const windowEnd = to ?? new Date(); + const floor = new Date( + windowEnd.getTime() - SLEEP_LIST_MAX_DAYS * 86_400_000, + ); + const since = from && from > floor ? from : floor; + const windowCut = from !== undefined && from < floor; const rows = await prisma.measurement.findMany({ where: { userId: user.id, @@ -690,7 +699,13 @@ async function sleepListResponse( }); return apiSuccess({ measurements, - meta: { total: totalNights, limit, offset, groupBy: "night" }, + meta: { + total: totalNights, + limit, + offset, + groupBy: "night", + ...(windowCut ? { windowFrom: since.toISOString() } : {}), + }, }); } diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index 06d46e07a..f42afb47c 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -49,11 +49,23 @@ const SLEEP_SERIES_MAX_DAYS = 365; * byte-identical. Sparse kinds (weight, BP, …) are untouched. */ const DENSE_SERIES_RAW_WINDOW_DAYS = 90; -const DENSE_SERIES_KINDS: ReadonlySet = new Set(["glucose", "pulse"]); +/** + * The kinds a device can write at sampling rate. Heart-rate variability and + * blood oxygen joined pulse and glucose once watches and rings began writing + * them every few minutes overnight: their 3650-day "Alle" range read every + * raw row, which is the read this rule exists to stop. + */ +const DENSE_SERIES_KINDS: ReadonlySet = new Set([ + "glucose", + "pulse", + "heartRateVariability", + "oxygenSaturation", +]); /** - * #1023 — inside the raw window, a pulse or glucose series with more rows - * than this is bucketed per local hour in SQL instead of sent raw. + * #1023 — inside the raw window, a dense-kind series (pulse, glucose, HRV, + * blood oxygen) with more rows than this is bucketed per local hour in SQL + * instead of sent raw. * * A watch that records heart rate once a minute puts 43 000 rows in the * default 30-day window and 130 000 in 90 days: a 15 MB response the client @@ -381,7 +393,7 @@ export const GET = apiHandler(async (request: NextRequest) => { }, })) > DENSE_SERIES_RAW_ROW_CAP) ) { - // Day buckets past the raw window; hour buckets for a pulse or CGM + // Day buckets past the raw window; hour buckets for a dense-kind // stream too dense to send raw inside it. const grain = days > DENSE_SERIES_RAW_WINDOW_DAYS ? "day" : "hour"; // v1.28.25 — long-window read of a sample-dense kind (CGM glucose, @@ -489,17 +501,19 @@ export const GET = apiHandler(async (request: NextRequest) => { }; } } else { - // pulse — `value` is the bucket's average; valueMin/valueMax carry the - // bucket's low/high band (folding each hourly import bucket's own + // `value` is the bucket's average. For pulse, valueMin/valueMax carry + // the bucket's low/high band (folding each hourly import bucket's own // spread via the COALESCE above), same band semantics as the per-hour - // shape. + // shape. The other dense kinds keep the bare point shape their raw rows + // have, so a client reads a bucket the way it reads a reading. points = bucketRows.map((r) => ({ id: dayId(r.bucket_start), at: r.bucket_start.toISOString(), value: round2(r.mean), secondary: null, - valueMin: r.min_value, - valueMax: r.max_value, + ...(kind === "pulse" + ? { valueMin: r.min_value, valueMax: r.max_value } + : {}), })); if ( rawAgg !== undefined && diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index 1899669fd..69ca8f41e 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -192,7 +192,10 @@ export default function LabsPage() { title={t("labs.addResult")} description={t("labs.addDescription")} footer={ -
+
} > {/* Desktop: table layout */} diff --git a/src/components/admin/app-log-preview-section.tsx b/src/components/admin/app-log-preview-section.tsx index 1cc49e2c1..f9b92a01d 100644 --- a/src/components/admin/app-log-preview-section.tsx +++ b/src/components/admin/app-log-preview-section.tsx @@ -125,7 +125,7 @@ export function AppLogPreviewSection() { } /> +

{t("admin.section.app-logs.processDetail")}

+
= 0 ? Math.max(0, min - pad) : min - pad, max + pad]; }, [points, lowerBound, upperBound, sourceBand]); const animate = !prefersReducedMotion(); diff --git a/src/components/labs/lab-form.tsx b/src/components/labs/lab-form.tsx index a27babb65..9b041f987 100644 --- a/src/components/labs/lab-form.tsx +++ b/src/components/labs/lab-form.tsx @@ -368,6 +368,10 @@ export function LabForm({ variant="outline" data-action="save-and-add-another" disabled={submitting} + // Three buttons do not fit one phone-width row: the secondary save + // takes a row of its own above Cancel + Save instead of pushing + // Cancel off the left edge of the sheet. + className="max-md:order-first max-md:w-full" > {pendingAction === "saveAndAddAnother" ? ( diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx index 55b397519..3dce4fa67 100644 --- a/src/components/labs/lab-history-list.tsx +++ b/src/components/labs/lab-history-list.tsx @@ -311,103 +311,148 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { }); }, [readings, sortBy, sortDir]); + // One action pair per reading, shared by the phone list and the table. + const renderActions = (r: LabResultDto) => ( +
+ {canManageLabs && ( + + )} + deleteMutation.mutate(r.id)} + title={t("labs.deleteConfirmTitle")} + description={t("labs.deleteConfirmDescription")} + confirmLabel={t("labs.deleteReading")} + className="size-11 sm:size-9" + iconClassName="h-4 w-4" + /> +
+ ); + return ( <> - - - - - - - - - {t("labs.editReading")} - - - - - {ordered.map((r) => ( - - -
- {formatLabReading(r)} - -
-
- - {formatDateShort(r.takenAt, true)} - - -
- - -
-
- - {r.hasNote ? t("labs.hasNote") : ""} - - -
- {canManageLabs && ( - - )} - deleteMutation.mutate(r.id)} - title={t("labs.deleteConfirmTitle")} - description={t("labs.deleteConfirmDescription")} - confirmLabel={t("labs.deleteReading")} - className="size-11 sm:size-9" - iconClassName="h-4 w-4" - /> -
-
+ {/* Phones get one stacked row per reading. The table needs ~510 px for + its five columns, so on a phone it panned sideways inside its + container and parked the edit / delete buttons off-screen, while the + range bar's end labels were clipped by the same container. */} +
    + {ordered.map((r) => ( +
  • +
    +
    + {formatLabReading(r)} +
    + +
    + + {formatDateShort(r.takenAt, true)} + + + {r.hasNote ? ( + {t("labs.hasNote")} + ) : null} +
    + +
    + {renderActions(r)} +
  • + ))} +
+
+
+ + + + + + + + {t("labs.editReading")} + - ))} - -
+ + + {ordered.map((r) => ( + + +
+ {formatLabReading(r)} + +
+
+ + {formatDateShort(r.takenAt, true)} + + +
+ + +
+
+ + {r.hasNote ? t("labs.hasNote") : ""} + + + {renderActions(r)} + +
+ ))} +
+ +
+
void } = {}) { const { t } = useTranslations(); const { canWriteDomain } = useRecordCapabilities(); @@ -212,12 +241,15 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {

) : null} - + {groups.map((group) => { const inner = ( -
+
- + {group.analyte}
@@ -234,47 +266,57 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { {inner} -
+
reading.value)} referenceLow={group.latest.referenceLow} referenceHigh={group.latest.referenceHigh} />
- + ) : ( // Un-linked group (not backfilled to a catalog biomarker): // render inert but say so, so it doesn't read as a broken // link next to its clickable neighbours. -
+
{inner} -
+
reading.value)} referenceLow={group.latest.referenceLow} referenceHigh={group.latest.referenceHigh} />
- + {t("labs.notLinkedYet")}
diff --git a/src/components/labs/ocr-row-editor.tsx b/src/components/labs/ocr-row-editor.tsx index ea1a06281..e63388a55 100644 --- a/src/components/labs/ocr-row-editor.tsx +++ b/src/components/labs/ocr-row-editor.tsx @@ -129,9 +129,17 @@ export function OcrRowEditor({ {fieldError("analyte", "labs.ocr.errorAnalyte")}
{row.biomarkerMatch === "existing" ? ( - + // The chip repeats the analyte, so a long panel name ran past + // the row on a phone; it truncates inside the row instead. + - {t("labs.ocr.linksExisting", { name: row.analyte })} + + {t("labs.ocr.linksExisting", { name: row.analyte })} + ) : ( diff --git a/src/components/settings/_card-header.tsx b/src/components/settings/_card-header.tsx index 29c04fb95..097e369e9 100644 --- a/src/components/settings/_card-header.tsx +++ b/src/components/settings/_card-header.tsx @@ -89,7 +89,12 @@ export function SettingsCardHeader({ {titleAccessory}
{status ? ( -
{status}
+ // `max-w-full` caps the slot at the row once it wraps under the + // title, so a status line longer than a phone row wraps inside + // the card instead of widening the page. +
+ {status} +
) : null}
{/* One sentence, one text run. This slot deliberately carries no diff --git a/src/components/settings/ai/fallback-chain-card.tsx b/src/components/settings/ai/fallback-chain-card.tsx index 388a04b1a..80977ecfb 100644 --- a/src/components/settings/ai/fallback-chain-card.tsx +++ b/src/components/settings/ai/fallback-chain-card.tsx @@ -294,7 +294,7 @@ function AddProviderControl({ const [picked, setPicked] = useState(addable[0] ?? ""); return ( -
+
@@ -898,7 +898,12 @@ function SortableWidgetRow({ {labels.widgetLabel} -
+ {/* On a phone the two switches and two arrows (~250 px) left the name + five characters ("Gewic…"). Below `sm` the controls wrap onto a + second line, right-aligned under the column heads, and the name + keeps the first line to itself. */} +