From ca0d80b85cbf95a29fc17784f06f05847d049d1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 23:00:16 +0100 Subject: [PATCH 01/10] test(insights): prove the health status cache against Postgres The route test that pinned the cached answer replaced the database client and the engines, so it saw the calls the route makes and not what a reader gets back. The integration test drives the shipped route: a reading written past the API leaves the second open on the stored answer, while a measurement written through the API and a module switch both show it. --- .../health-status/__tests__/route.test.ts | 113 ------------- tests/integration/health-status-cache.test.ts | 157 ++++++++++++++++++ 2 files changed, 157 insertions(+), 113 deletions(-) delete mode 100644 src/app/api/insights/health-status/__tests__/route.test.ts create mode 100644 tests/integration/health-status-cache.test.ts diff --git a/src/app/api/insights/health-status/__tests__/route.test.ts b/src/app/api/insights/health-status/__tests__/route.test.ts deleted file mode 100644 index 4f2b428e1..000000000 --- a/src/app/api/insights/health-status/__tests__/route.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -/** - * The health status card reads two engines that walk every vital over up to - * 180 days. The answer is kept in the record's analytics cell: a second open - * of Insights serves it, and a write that sweeps the record's cells, or a - * module switch, computes it again. - */ - -const computeCoincidentDeviation = vi.fn(); -const buildExtendedChangepointSignals = vi.fn(); -const resolveModuleMap = vi.fn(); - -vi.mock("@/lib/insights/derived/coincident-deviation", () => ({ - computeCoincidentDeviation: (...args: unknown[]) => - computeCoincidentDeviation(...args), -})); -vi.mock("@/lib/insights/derived/changepoint", () => ({ - buildExtendedChangepointSignals: (...args: unknown[]) => - buildExtendedChangepointSignals(...args), -})); -vi.mock("@/lib/insights/derived", () => ({ - loadBaselineProfile: vi.fn().mockResolvedValue({ ageYears: 40 }), -})); -vi.mock("@/lib/db", () => ({ - prisma: { - auditLog: { create: vi.fn() }, - appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, - }, -})); -vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); -vi.mock("@/lib/auth/audit", () => ({ - auditLog: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("@/lib/modules/gate", async (importOriginal) => ({ - ...(await importOriginal()), - resolveModuleMap: (userId: string) => resolveModuleMap(userId), -})); -vi.mock("@/lib/rate-limit", () => ({ - checkAnalyticsReadRateLimit: vi.fn(), -})); -vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -import { GET } from "../route"; -import { getSession } from "@/lib/auth/session"; -import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; -import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; -import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; - -const call = () => GET(); - -beforeEach(() => { - __resetAllCachesForTests(); - vi.mocked(getSession).mockResolvedValue({ - session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, - user: { - id: "user-1", - username: "testuser", - role: "USER", - timezone: "Europe/Berlin", - }, - } as unknown as Awaited>); - vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({ - allowed: true, - } as unknown as Awaited>); - computeCoincidentDeviation - .mockReset() - .mockResolvedValue({ status: "insufficient" }); - buildExtendedChangepointSignals.mockReset().mockResolvedValue([]); - resolveModuleMap.mockReset().mockResolvedValue({}); -}); - -describe("GET /api/insights/health-status — the cached answer", () => { - it("computes once for two opens", async () => { - const first = await call(); - const second = await call(); - - expect(first.status).toBe(200); - expect(second.status).toBe(200); - expect(computeCoincidentDeviation).toHaveBeenCalledTimes(1); - expect(buildExtendedChangepointSignals).toHaveBeenCalledTimes(1); - expect((await second.json()).data).toEqual((await first.json()).data); - }); - - it("computes again after a measurement write", async () => { - await call(); - invalidateUserMeasurements("user-1", { evict: true }); - await call(); - - expect(computeCoincidentDeviation).toHaveBeenCalledTimes(2); - }); - - it("computes again when a module is switched off", async () => { - await call(); - resolveModuleMap.mockResolvedValue({ sleep: false }); - await call(); - - expect(buildExtendedChangepointSignals).toHaveBeenCalledTimes(2); - expect(buildExtendedChangepointSignals).toHaveBeenLastCalledWith( - "user-1", - expect.any(Date), - { sleep: false }, - ); - }); -}); diff --git a/tests/integration/health-status-cache.test.ts b/tests/integration/health-status-cache.test.ts new file mode 100644 index 000000000..42253cf6f --- /dev/null +++ b/tests/integration/health-status-cache.test.ts @@ -0,0 +1,157 @@ +/** + * `GET /api/insights/health-status` answers from the record's analytics cell, + * against Postgres. + * + * Both engines behind the card walk every vital over up to 180 days, so a + * second open of Insights serves the stored answer. A reading written past + * the API (no sweep) is the witness: the cell still holds the answer from + * before it. A measurement written through the API sweeps the cell, and a + * module switch changes the key, so either one shows the reading. All data + * invented. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { GET as getHealthStatus } from "@/app/api/insights/health-status/route"; +import { POST as createMeasurement } from "@/app/api/measurements/route"; +import { PATCH as patchModules } from "@/app/api/auth/me/modules/route"; +import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; +import { startOfLocalDayInTz } from "@/lib/tz/local-day"; + +import { callRoute, type RouteAuth } from "../kit/call-route"; +import { makeMeasurement, makeSession, makeUser } from "../kit/factories"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const prisma = getPrismaClient(); +const TZ = "Europe/Berlin"; +const DAY_MS = 86_400_000; +/** Well above the fortnight's 64 to 68 bpm, so the card reports it. */ +const RAISED_PULSE = 112; + +interface HealthStatusBody { + present: boolean; + deviations: Array<{ type: string; value: number }>; + shifts: unknown[]; + generatedAt: string; +} + +async function readHealthStatus(auth: RouteAuth) { + const res = await callRoute(getHealthStatus, { + method: "GET", + route: "/api/insights/health-status", + auth, + }); + expect(res.status).toBe(200); + return res.data; +} + +/** + * A fortnight of ordinary pulse and weight readings, one per earlier local + * day at noon: the coincident engine needs two banded vitals before it + * reports. + */ +async function seedFortnight(userId: string) { + const todayStart = startOfLocalDayInTz(new Date(), TZ).getTime(); + const rows = []; + for (let d = 14; d >= 1; d -= 1) { + const measuredAt = new Date(todayStart - d * DAY_MS + DAY_MS / 2); + rows.push( + { + userId, + type: "PULSE" as const, + value: 64 + (d % 5), + unit: "bpm", + measuredAt, + source: "MANUAL" as const, + }, + { + userId, + type: "WEIGHT" as const, + value: 80 + (d % 3) * 0.2, + unit: "kg", + measuredAt, + source: "MANUAL" as const, + }, + ); + } + await prisma.measurement.createMany({ data: rows }); +} + +async function signedInWithFortnight() { + const user = await makeUser({ timezone: TZ }); + await seedFortnight(user.id); + return { user, auth: await makeSession(user) }; +} + +const pulseDeviation = (body: HealthStatusBody) => + body.deviations.find((d) => d.type === "PULSE"); + +beforeEach(async () => { + await truncateAllTables(prisma); + __resetAllCachesForTests(); +}); + +describe("GET /api/insights/health-status — the cached answer", () => { + it("serves the second open from the cell", async () => { + const { user, auth } = await signedInWithFortnight(); + const first = await readHealthStatus(auth); + expect(pulseDeviation(first)).toBeUndefined(); + + // Written past the API: nothing sweeps the cell. + await makeMeasurement(user, { + type: "PULSE", + value: RAISED_PULSE, + unit: "bpm", + measuredAt: new Date(Date.now() - 60_000), + }); + const second = await readHealthStatus(auth); + + expect(second).toEqual(first); + }); + + it("computes again after a measurement written through the API", async () => { + const { auth } = await signedInWithFortnight(); + expect(pulseDeviation(await readHealthStatus(auth))).toBeUndefined(); + + const write = await callRoute(createMeasurement, { + method: "POST", + route: "/api/measurements", + body: { + type: "PULSE", + value: RAISED_PULSE, + unit: "bpm", + measuredAt: new Date(Date.now() - 60_000).toISOString(), + }, + auth, + }); + expect(write.status).toBe(201); + + expect(pulseDeviation(await readHealthStatus(auth))?.value).toBeCloseTo( + RAISED_PULSE, + 6, + ); + }); + + it("computes again when a module is switched off", async () => { + const { user, auth } = await signedInWithFortnight(); + expect(pulseDeviation(await readHealthStatus(auth))).toBeUndefined(); + + await makeMeasurement(user, { + type: "PULSE", + value: RAISED_PULSE, + unit: "bpm", + measuredAt: new Date(Date.now() - 60_000), + }); + const patch = await callRoute(patchModules, { + method: "PATCH", + route: "/api/auth/me/modules", + body: { mood: false }, + auth, + }); + expect(patch.status).toBe(200); + + expect(pulseDeviation(await readHealthStatus(auth))?.value).toBeCloseTo( + RAISED_PULSE, + 6, + ); + }); +}); From 05b5eb694228c596f7438357a39c3cc2a8ac014e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 23:00:16 +0100 Subject: [PATCH 02/10] test(medications): prove the dose history window against Postgres The cases for the bounded read asserted the query's where clause on a mocked client and cast their fixtures past the type checker. Against Postgres they now show what the read promises: a rolling medication last taken before the window shows the dose that fell due inside it, and a year of earlier takes answers the same ledger as a short history. --- .../[id]/dose-history/__tests__/route.test.ts | 71 ----------- tests/integration/dose-history-window.test.ts | 117 ++++++++++++++++++ 2 files changed, 117 insertions(+), 71 deletions(-) create mode 100644 tests/integration/dose-history-window.test.ts diff --git a/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts b/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts index 8edbfb47d..303565453 100644 --- a/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/dose-history/__tests__/route.test.ts @@ -567,74 +567,3 @@ describe("GET /api/medications/[id]/dose-history — slots before creation (#102 }); } }); - -/** - * The history reads the window's rows, not every intake the medication ever - * had: for a medication taken for years each open of the tab read thousands - * of rows to show at most a year. Of the rows before the window only the - * latest take is read, because a rolling grid re-starts from it. - */ -describe("GET /api/medications/[id]/dose-history — the read is bounded to the window", () => { - const FROM = new Date("2026-06-01T00:00:00Z"); - const TO = new Date("2026-06-15T00:00:00Z"); - const windowQuery = `?from=${encodeURIComponent(FROM.toISOString())}&to=${encodeURIComponent(TO.toISOString())}`; - - it("asks only for rows anchored or taken in the window", async () => { - await GET(getReq(windowQuery), ROUTE_PARAMS); - - const where = vi.mocked(prisma.medicationIntakeEvent.findMany).mock - .calls[0][0]?.where; - expect(where).toMatchObject({ - OR: [{ scheduledFor: { gte: FROM } }, { takenAt: { gte: FROM } }], - }); - const before = vi.mocked(prisma.medicationIntakeEvent.findFirst).mock - .calls[0][0]; - expect(before?.where).toMatchObject({ - skipped: false, - scheduledFor: { lt: FROM }, - takenAt: { lt: FROM }, - }); - }); - - it("re-starts a rolling grid from the latest take before the window", async () => { - vi.mocked(prisma.medication.findUnique).mockResolvedValue({ - id: "med-1", - startsOn: null, - endsOn: null, - oneShot: false, - createdAt: new Date("2026-01-01T00:00:00Z"), - schedules: [ - { - id: "sched-1", - windowStart: "08:00", - windowEnd: "08:00", - daysOfWeek: null, - timesOfDay: ["08:00"], - reminderGraceMinutes: null, - rrule: null, - rollingIntervalDays: 7, - scheduleType: "SCHEDULED", - cyclicOnWeeks: null, - cyclicOffWeeks: null, - }, - ], - } as never); - // Taken four days before the window opens, nothing since: the next dose - // was due inside the window and reads as missed there. - vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({ - takenAt: new Date("2026-05-28T06:00:00Z"), - } as never); - - const res = await GET(getReq(windowQuery), ROUTE_PARAMS); - expect(res.status).toBe(200); - const rows = (await res.json()).data.rows as Array<{ - at: string; - kind: string; - status: string; - }>; - - expect(rows.map((r) => [r.at.slice(0, 10), r.kind, r.status])).toEqual([ - ["2026-06-04", "slot", "missed"], - ]); - }); -}); diff --git a/tests/integration/dose-history-window.test.ts b/tests/integration/dose-history-window.test.ts new file mode 100644 index 000000000..5068770d3 --- /dev/null +++ b/tests/integration/dose-history-window.test.ts @@ -0,0 +1,117 @@ +/** + * `GET /api/medications/{id}/dose-history` reads the window it shows, against + * Postgres. + * + * Of the rows before the window only the latest take is read: a rolling grid + * re-starts from it, and earlier takes place no slot in the window. So a + * long history before the window answers the same ledger as a short one, and + * a rolling medication last taken before the window shows the dose that fell + * due inside it. All data invented. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { GET as getDoseHistory } from "@/app/api/medications/[id]/dose-history/route"; + +import { callRoute, type RouteAuth } from "../kit/call-route"; +import { makeMedication, makeSession, makeUser } from "../kit/factories"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const prisma = getPrismaClient(); +const TZ = "Europe/Berlin"; +const DAY_MS = 86_400_000; +const FROM = new Date("2026-06-01T00:00:00Z"); +const TO = new Date("2026-06-15T00:00:00Z"); + +interface LedgerBody { + rows: Array<{ at: string; kind: string; status: string }>; +} + +async function ledger(auth: RouteAuth, id: string) { + const res = await callRoute(getDoseHistory, { + method: "GET", + route: "/api/medications/{id}/dose-history", + params: { id }, + query: { from: FROM.toISOString(), to: TO.toISOString() }, + auth, + }); + expect(res.status).toBe(200); + return res.data.rows.map((r) => [r.at.slice(0, 10), r.kind, r.status]); +} + +/** Doses taken on time at 08:00 Berlin (06:00 UTC in summer), one per day. */ +async function takeOn(userId: string, medicationId: string, days: string[]) { + await prisma.medicationIntakeEvent.createMany({ + data: days.map((day) => { + const at = new Date(`${day}T06:00:00Z`); + return { + userId, + medicationId, + scheduledFor: at, + takenAt: at, + skipped: false, + source: "WEB" as const, + }; + }), + }); +} + +const isoDay = (ms: number) => new Date(ms).toISOString().slice(0, 10); + +beforeEach(async () => { + await truncateAllTables(prisma); +}); + +describe("the dose history of a window", () => { + it("re-starts a rolling grid from the latest take before the window", async () => { + const user = await makeUser({ timezone: TZ }); + const auth = await makeSession(user); + const med = await makeMedication( + user, + { createdAt: new Date("2026-01-01T00:00:00Z") }, + [ + { + windowStart: "08:00", + windowEnd: "08:00", + timesOfDay: ["08:00"], + rollingIntervalDays: 7, + }, + ], + ); + // Last taken four days before the window opens and not after: the next + // dose was due inside the window and reads as missed there. + await takeOn(user.id, med.id, ["2026-05-14", "2026-05-21", "2026-05-28"]); + + expect(await ledger(auth, med.id)).toEqual([ + ["2026-06-04", "slot", "missed"], + ]); + }); + + it("answers the same ledger after a year of earlier takes", async () => { + const user = await makeUser({ timezone: TZ }); + const auth = await makeSession(user); + const daily = [ + { + windowStart: "08:00", + windowEnd: "08:00", + timesOfDay: ["08:00"], + }, + ]; + const created = { createdAt: new Date("2025-05-01T00:00:00Z") }; + const short = await makeMedication(user, created, daily); + const long = await makeMedication(user, created, daily); + const inWindow = ["2026-06-02", "2026-06-03", "2026-06-07"]; + await takeOn(user.id, short.id, ["2026-05-31", ...inWindow]); + const yearBefore: string[] = []; + for (let ms = Date.parse("2025-05-01"); ms <= Date.parse("2026-05-31");) { + yearBefore.push(isoDay(ms)); + ms += DAY_MS; + } + await takeOn(user.id, long.id, [...yearBefore, ...inWindow]); + + const shortLedger = await ledger(auth, short.id); + expect(shortLedger.filter((r) => r[2] === "taken_on_time")).toEqual( + inWindow.map((day) => [day, "slot", "taken_on_time"]), + ); + expect(await ledger(auth, long.id)).toEqual(shortLedger); + }); +}); From 367fcd5332a14082b0d3c0c10ccb55847cbaa916 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 12:56:16 +0100 Subject: [PATCH 03/10] fix(insights): gate the tab strip on one presence read The tab strip on every Insights page read the whole overview (22 KB), a workouts page, the ECG list and the analytics summaries to learn which pills to show. GET /api/insights/presence answers just that, for the record on screen: the reading count per type after the record's module switches, and whether it has mood entries in the last 90 days, a tracked scheduled medication, workouts and ECG recordings. A flag whose page a switched-off module hides is false and its rows are not read. The strip now reads this and the saved layout; each page keeps loading its own data. --- docs/api/openapi.yaml | 79 ++++++++ .../baselines/version-stamp-comments.json | 4 +- .../client-poll-inventory-guard.test.ts | 5 - src/__tests__/sharing-surface-guard.test.ts | 14 +- src/app/api/insights/presence/route.ts | 97 +++++++++ .../insights-layout-shell-reads.test.tsx | 41 ++-- .../insights/insights-layout-shell.tsx | 128 ++++-------- src/lib/insights/metric-availability.ts | 18 +- src/lib/openapi/routes/insights/paths.ts | 24 +++ src/lib/openapi/routes/insights/schemas.ts | 30 +++ src/lib/query-keys/insights.ts | 2 + tests/integration/insights-presence.test.ts | 184 ++++++++++++++++++ 12 files changed, 497 insertions(+), 129 deletions(-) create mode 100644 src/app/api/insights/presence/route.ts create mode 100644 tests/integration/insights-presence.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 443b3d55a..7e17a0f95 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -13115,6 +13115,27 @@ paths: "403": *a20 "422": *a4 "429": *a5 + /api/insights/presence: + get: + tags: + - Insights + summary: Which Insights pages have data + description: "What the Insights tab strip gates its pills on, for the record on screen: the reading count per + measurement type (after the record's module switches) and whether the record has mood entries in the last 90 + days, a tracked scheduled medication, workouts and ECG recordings. A flag whose module is off is false. Counts + and flags only; each page reads its own data. No provider is called and no AI switch refuses it. Delegable at + MANAGE level over the whole record. Cookie or Bearer auth." + responses: + "200": + description: The presence answer. + content: + application/json: + schema: + $ref: "#/components/schemas/InsightsPresenceEnvelope" + "401": *a1 + "403": *a20 + "422": *a4 + "429": *a5 /api/insights/cards: get: tags: @@ -34933,6 +34954,7 @@ components: - BLOOD_PRESSURE_SYS - PULSE - BLOOD_GLUCOSE + - BLOOD_KETONES - OXYGEN_SATURATION - BODY_TEMPERATURE - BODY_FAT @@ -68518,6 +68540,63 @@ components: additionalProperties: false description: A significant Pearson correlation (n >= 20, p < 0.05). The field holding it is null when the pairing is not significant. + InsightsPresenceEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InsightsPresence" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + InsightsPresence: + type: object + properties: + metrics: + type: object + propertyNames: + type: string + additionalProperties: + type: object + properties: + count: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - count + additionalProperties: false + description: "Keyed by `MeasurementType`: the reading count of every type with at least one reading, after the record's + module switches. The same counts `GET /api/analytics?slice=summaries` reports." + hasMood: + type: boolean + description: A mood entry in the last 90 days. False while the mood module is off. + hasMedication: + type: boolean + description: An active medication with a schedule whose intake is tracked. False while the medications module is off. + hasWorkouts: + type: boolean + description: At least one workout. False while the workouts module is off. + hasEcgRecordings: + type: boolean + description: At least one ECG recording. + required: + - metrics + - hasMood + - hasMedication + - hasWorkouts + - hasEcgRecordings + additionalProperties: false + description: "Which Insights pages have something to show for the record: what the tab strip gates its pills on. Counts + and flags only, no values." InsightsCardsEnvelope: type: object properties: diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index bd8617a0b..10a8c80c5 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", "count": 2876, - "total": 9172, + "total": 9164, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -982,7 +982,7 @@ "components/insights/hero-strip.tsx": 3, "components/insights/insight-status-card.tsx": 12, "components/insights/insights-edit-mode.tsx": 5, - "components/insights/insights-layout-shell.tsx": 11, + "components/insights/insights-layout-shell.tsx": 3, "components/insights/insights-tab-strip.tsx": 35, "components/insights/intraday-pulse-chart.tsx": 1, "components/insights/labs-changes-card.tsx": 1, diff --git a/src/__tests__/client-poll-inventory-guard.test.ts b/src/__tests__/client-poll-inventory-guard.test.ts index 48d16b993..95d17ae01 100644 --- a/src/__tests__/client-poll-inventory-guard.test.ts +++ b/src/__tests__/client-poll-inventory-guard.test.ts @@ -136,11 +136,6 @@ const INVENTORY: Readonly> = { cadence: "25 s, nextAdvisorPollInterval, attempts from the response timeout so the warm can land (advisor); 10 while settling", }, - "components/insights/insights-layout-shell.tsx": { - sites: 1, - shape: "bounded", - cadence: "25 s, nextAdvisorPollInterval, 10 attempts (briefing body)", - }, }; const SITE = /\brefetchInterval\s*:/g; diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index d86d5e959..0609e0dcc 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -1511,6 +1511,10 @@ const DELEGABLE_ROUTES: Record = { domain: "record", why: "The heaviest SQL aggregation in the product, cached per record; no provider on the path. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", }, + "app/api/insights/presence/route.ts": { + domain: "record", + why: "Which Insights pages have data: reading counts per type and four presence flags, the gate of the tab strip over the overview. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, "app/api/insights/correlations/route.ts": { domain: "record", why: "Correlations over the record's own series. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", @@ -2049,6 +2053,11 @@ const DELEGABLE_MANAGE_ROUTES: Record = { conditions: [], why: "The heaviest SQL aggregation in the product, cached per record; no provider on the path.", }, + "app/api/insights/presence/route.ts": { + domain: "record", + conditions: [], + why: "Counts and flags only, no values, from the cells the overview and the summaries slice already serve; no provider on the path.", + }, "app/api/insights/correlations/route.ts": { domain: "record", conditions: [], @@ -2304,8 +2313,11 @@ const ACTOR_ROUTES: Record = { * list: a standing credential to a third-party calendar is the owner's to * make, or a managed profile's guardian's, never an adult record's delegate's. * 276 -> 278. + * + * The Insights presence read joins the record list and the manage literal: + * the tab strip's gate over the overview, counts and flags only. 278 -> 280. */ -const FROZEN_ENTRY_COUNT = 278; +const FROZEN_ENTRY_COUNT = 280; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/insights/presence/route.ts b/src/app/api/insights/presence/route.ts new file mode 100644 index 000000000..541ca3d9c --- /dev/null +++ b/src/app/api/insights/presence/route.ts @@ -0,0 +1,97 @@ +/** + * GET /api/insights/presence — which Insights pages have something to show. + * + * The tab strip on every Insights page gates its pills on this answer alone: + * the reading count per measurement type, and whether the record has mood + * entries, a scheduled medication, workouts and ECG recordings. The counts are + * the summaries slice's cell, after the record's module switches; mood and + * medication mean what the overview means by them. A flag whose page a + * switched-off module hides (`src/lib/modules/surface.ts`) is false, and its + * rows are not read. + * + * No AI gate: every field is computed from the record. MANAGE-level read over + * the whole record, like the overview the strip sits on. + */ +import { apiError, apiSuccess } from "@/lib/api-response"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; +import { prisma } from "@/lib/db"; +import { readSummariesSlice } from "@/lib/analytics/summaries-slice"; +import { gateSummariesByModules } from "@/lib/dashboard/widget-modules"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import { isSurfaceVisible } from "@/lib/modules/surface"; +import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; + +export const dynamic = "force-dynamic"; + +/** The overview's mood window: a mood pill needs an entry inside it. */ +const MOOD_WINDOW_MS = 90 * 24 * 60 * 60 * 1000; + +export const GET = apiHandler(async () => { + const { user } = await requireRecordAuth("manage", "record"); + + const rl = await checkAnalyticsReadRateLimit(user.id); + if (!rl.allowed) { + return apiError("Too many analytics requests. Please retry later.", 429); + } + + const modules = await resolveModuleMap(user.id); + const shows = (slug: string) => + isSurfaceVisible(`insights-page:${slug}`, modules); + const userId = user.id; + const [slice, mood, medication, workout, ecg] = await Promise.all([ + readSummariesSlice(userId, { serveStale: true }), + shows("mood") + ? prisma.moodEntry.findFirst({ + where: { + userId, + deletedAt: null, + moodLoggedAt: { gte: new Date(Date.now() - MOOD_WINDOW_MS) }, + }, + select: { id: true }, + }) + : null, + // A medication with no schedule, an as-needed one or one whose intake is + // not tracked expects no dose, so the overview lists no rate for it. + shows("medications") + ? prisma.medication.findFirst({ + where: { + userId, + active: true, + asNeeded: false, + ...TRACKED_INTAKE_WHERE, + schedules: { some: {} }, + }, + select: { id: true }, + }) + : null, + shows("workouts") + ? prisma.workout.findFirst({ where: { userId }, select: { id: true } }) + : null, + prisma.ecgRecording.findFirst({ where: { userId }, select: { id: true } }), + ]); + + const { summaries } = gateSummariesByModules( + slice.summaries, + slice.lastSeenByType, + modules, + ); + const metrics: Record = {}; + for (const [type, summary] of Object.entries(summaries)) { + if (summary.count > 0) metrics[type] = { count: summary.count }; + } + + annotate({ + action: { name: "insights.presence.read" }, + meta: { metrics: Object.keys(metrics).length }, + }); + + return apiSuccess({ + metrics, + hasMood: mood !== null, + hasMedication: medication !== null, + hasWorkouts: workout !== null, + hasEcgRecordings: ecg !== null, + }); +}); diff --git a/src/components/insights/__tests__/insights-layout-shell-reads.test.tsx b/src/components/insights/__tests__/insights-layout-shell-reads.test.tsx index 1d6bfb37c..d50c4da31 100644 --- a/src/components/insights/__tests__/insights-layout-shell-reads.test.tsx +++ b/src/components/insights/__tests__/insights-layout-shell-reads.test.tsx @@ -6,6 +6,10 @@ import { renderToStaticMarkup } from "react-dom/server"; * on the tab strip but shows no briefing, so it must not read the briefing * on entry: on a metric page that read was a request (and the schema chunk * that parses its answer) for a button. The button itself stays. + * + * The strip's pills are gated on one small presence read and the saved + * layout. The overview read, a workouts page and the ECG list it used to + * send on every page for four flags are gone. */ const queries: Array<{ queryKey: readonly unknown[]; enabled?: boolean }> = []; @@ -29,23 +33,12 @@ vi.mock("@/hooks/use-ai-capability", () => ({ useAiCapability: () => ({ available: true }), useAiProviderState: () => ({ responseTimeoutMs: null }), })); -vi.mock("@/hooks/use-surface-visible", () => ({ - useSurfaceVisible: () => true, -})); -vi.mock("@/hooks/use-workouts", () => ({ - useWorkouts: () => ({ data: undefined }), -})); -vi.mock("@/lib/queries/use-analytics-query", () => ({ - useAnalyticsQuery: () => ({ data: undefined }), -})); +const layoutReads: unknown[] = []; vi.mock("@/hooks/use-insights-layout", () => ({ - useInsightsLayoutQuery: () => ({ - layout: { tiles: [] }, - isSuccess: false, - }), -})); -vi.mock("@/components/insights/use-ecg-list", () => ({ - useEcgList: () => ({ data: undefined }), + useInsightsLayoutQuery: (enabled: unknown) => { + layoutReads.push(enabled); + return { layout: { tiles: [] }, isSuccess: false }; + }, })); vi.mock("@/components/insights/insights-tab-strip", () => ({ InsightsTabStrip: (props: { onRegenerate?: unknown }) => { @@ -73,4 +66,20 @@ describe("InsightsLayoutShell — what it reads on entry", () => { expect(advisor[0].enabled).toBe(false); expect(typeof stripProps[0]?.onRegenerate).toBe("function"); }); + + it("reads the presence answer and the layout, nothing else", () => { + queries.length = 0; + layoutReads.length = 0; + renderToStaticMarkup( + +
+ , + ); + + const sent = queries + .filter((q) => q.enabled !== false) + .map((q) => JSON.stringify(q.queryKey)); + expect(sent).toEqual([JSON.stringify(queryKeys.insightsPresence())]); + expect(layoutReads).toEqual([true]); + }); }); diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx index 7bba6a385..4e0481c73 100644 --- a/src/components/insights/insights-layout-shell.tsx +++ b/src/components/insights/insights-layout-shell.tsx @@ -6,20 +6,12 @@ import { useQuery } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; import { useAiCapability } from "@/hooks/use-ai-capability"; -import { useSurfaceVisible } from "@/hooks/use-surface-visible"; -import { useWorkouts } from "@/hooks/use-workouts"; import { InsightsTabStrip } from "@/components/insights/insights-tab-strip"; -import { - nextAdvisorPollInterval, - useInsightsAdvisorQuery, -} from "@/components/insights/use-insights-advisor"; -import { pollAttemptCount } from "@/lib/queries/refetch-interval"; -import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; +import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor"; import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout"; import { queryKeys } from "@/lib/query-keys"; import type { InsightInputs } from "@/lib/insights/metric-availability"; import { apiGet } from "@/lib/api/api-fetch"; -import { useEcgList } from "@/components/insights/use-ecg-list"; /** * v1.4.25 — client shell for `src/app/insights/layout.tsx`. @@ -31,26 +23,22 @@ import { useEcgList } from "@/components/insights/use-ecg-list"; * page used to send the briefing read (and load the schema chunk that * parses it) for a button. * - * v1.4.27 — also owns the analytics + comprehensive reads that - * power the tab-strip availability gate. The analytics consumer goes - * through the shared `useAnalyticsQuery({ slice: "summaries" })` hook - * (v1.4.33 IW2); the comprehensive consumer still owns its bespoke - * fetch but shares its cache key with the sub-page consumers so the - * payload lands once per route, dedup-shared across consumers. + * The tab-strip gate reads one small answer, `/api/insights/presence` + * (reading counts per type, and whether mood, medication, workout and ECG + * rows exist), plus the saved layout. Each page loads its own data; the + * strip used to read the overview, a workouts page and the ECG list on + * every page for four flags. * * CRITICAL — the `` does NOT mount here. It lives only in * `src/app/insights/page.tsx` body so navigating into a sub-page * unmounts the drawer. */ -interface ComprehensivePayload { - moodSummary: { count: number } | null; - medications: Array<{ id: string }>; - /** - * v1.16.7 — true when the route served a stale cache body while a - * background rebuild runs (stale-while-revalidate). The query polls - * (bounded) while set so the fresh aggregate lands in-session. - */ - revalidating?: boolean; +interface InsightsPresence { + metrics: Record; + hasMood: boolean; + hasMedication: boolean; + hasWorkouts: boolean; + hasEcgRecordings: boolean; } export function InsightsLayoutShell({ children }: { children: ReactNode }) { @@ -70,83 +58,33 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { const advisor = useInsightsAdvisorQuery(false); - // Shared analytics fetch — the layout shell only reads - // `summaries[METRIC].count` for the tab-strip availability gate, so - // it lands on IW1's slim `?slice=summaries` branch (2 SQL passes, - // no correlations / health-score / bp-in-target tail). - const analyticsQuery = useAnalyticsQuery({ slice: "summaries" }); - - // Shared comprehensive fetch — mood + medication signals for the - // event-driven gating branches. Sub-pages read the same key so the - // payload lands once per route. - const comprehensiveQuery = useQuery({ - queryKey: queryKeys.insightsComprehensive(), - queryFn: async () => { - return apiGet("/api/insights/comprehensive"); - }, + // Answered for the record on screen. A page whose module is off reads + // false here, and the strip hides its pill through the surface map anyway. + const presenceQuery = useQuery({ + queryKey: queryKeys.insightsPresence(), + queryFn: () => apiGet("/api/insights/presence"), enabled: isAuthenticated, - // v1.16.7 — converge a stale-served (SWR) body in-session: while the - // route reports `revalidating: true`, poll on the same bounded - // cadence the advisor query uses until a fresh body lands. - refetchInterval: (query) => - nextAdvisorPollInterval( - query.state.data?.revalidating, - pollAttemptCount(query.state), - ), }); + const presence = presenceQuery.data; - // v1.4.32 — workout-presence gate. The workouts pill gates on at - // least one canonical row in the list-endpoint response. The - // single-row probe shares its cache slot with the list page + - // dashboard tile so navigation between surfaces is a free cache hit. - // Only while the Workouts module shows: a switched-off module is never - // probed (its pill is hidden by the surface map regardless). - const workoutsVisible = useSurfaceVisible("insights-page:workouts"); - const workoutsProbe = useWorkouts({ limit: 1, enabled: workoutsVisible }); + // Nutrients is opt-in, so its pill follows the module toggle alone: the + // in-context enable CTA lives on `/insights/nutrients` itself, and gating + // on a first row as well left that page unreachable before data existed. + const hasNutrients = user?.modules?.nutrients === true; + const hasEcgRecordings = presence?.hasEcgRecordings === true; - // v1.29 — nutrients gate. `nutrients` is opt-in (default off), unlike - // the always-on metric clusters, so the pill's floor is the module - // toggle alone — not a data-presence probe. Gating on BOTH the toggle - // AND a first row was a catch-22: the in-context enable CTA lives on - // `/insights/nutrients` itself, and that page was only reachable once - // data already existed. A user who just flipped the module on (or - // wants to) can now always reach the page; the page itself still - // degrades from "enable" → "no rows yet" → the three-card spine. - const nutrientsEnabled = user?.modules?.nutrients === true; - - // v1.30 — ECG recording presence for the Heart-group ECG - // pill. ECG carries no `MeasurementType`, so the metric `availability` - // model can't gate it; it rides its own `hasRecordings` probe instead. The - // query shares `queryKeys.insightsEcgList()` with `` and the - // ECG page, so the flag lands once per route (dedup-shared, no extra - // traffic). Undefined until it resolves → the pill stays off on first paint, - // exactly like the metric pills light up as their summaries land. - const ecgProbe = useEcgList(); - const hasEcgRecordings = ecgProbe.data?.data.hasRecordings === true; - - // v1.4.31 — memoise the `availability` prop so an unchanged - // payload doesn't recreate the object on every cache-write of - // analytics or comprehensive. The strip is wrapped in - // `React.memo` and would still rerun `buildTabs(availability)` on - // every parent render if this prop kept arriving as a fresh - // reference. Per - // `.planning/research/v15-insights-blocking-bug.md` fix 2. - const summaries = analyticsQuery.data?.summaries; - const hasMood = (comprehensiveQuery.data?.moodSummary?.count ?? 0) > 0; - const hasMedication = (comprehensiveQuery.data?.medications?.length ?? 0) > 0; - const hasWorkouts = (workoutsProbe.data?.workouts.length ?? 0) > 0; - const hasNutrients = nutrientsEnabled; + // Memoised so an unchanged answer keeps the same reference: the strip is + // wrapped in `React.memo` and reruns `buildTabs` on a new object. const availability: InsightInputs | undefined = useMemo(() => { if (!isAuthenticated) return undefined; - return { summaries, hasMood, hasMedication, hasWorkouts, hasNutrients }; - }, [ - isAuthenticated, - summaries, - hasMood, - hasMedication, - hasWorkouts, - hasNutrients, - ]); + return { + summaries: presence?.metrics, + hasMood: presence?.hasMood === true, + hasMedication: presence?.hasMedication === true, + hasWorkouts: presence?.hasWorkouts === true, + hasNutrients, + }; + }, [isAuthenticated, presence, hasNutrients]); // v1.15.14 — couple the tab strip to the saved insights layout. // A sub-page pill now shows iff its metric has data AND its slug is diff --git a/src/lib/insights/metric-availability.ts b/src/lib/insights/metric-availability.ts index 5ec96d5b0..06ec69979 100644 --- a/src/lib/insights/metric-availability.ts +++ b/src/lib/insights/metric-availability.ts @@ -19,9 +19,8 @@ * `summaries[METRIC].count` from the analytics endpoint. * - `BMI` is derived from `WEIGHT` so it inherits the weight count. * - Event-driven metrics (`MOOD`, `MEDICATION`) read the boolean - * `hasMood` / `hasMedication` flags the caller threads in from - * `/api/insights/comprehensive` (mood summary count > 0, - * medications array length > 0). + * `hasMood` / `hasMedication` flags the caller threads in (the tab + * strip from `/api/insights/presence`). * * Auto-light-up behaviour: once the iOS native client uploads its * first Apple-Health measurement, `summaries[METRIC].count` flips @@ -29,7 +28,6 @@ * pill, sub-page, and dashboard tile — no separate feature flag * needed. (F18.) */ -import type { DataSummary } from "@/lib/analytics/trends"; /** * Metric keys the gating helper distinguishes. Mirrors the @@ -133,14 +131,14 @@ export type InsightMetric = | "NUTRIENTS"; /** - * Inputs the gating helper consumes. The `summaries` shape mirrors - * the `/api/analytics` response (`Record`) - * — undefined means the caller hasn't loaded analytics yet, which - * the helper treats as "not available" so loading shells aren't - * accidentally lit up. + * Inputs the gating helper consumes. `summaries` is keyed by + * `MeasurementType` and only its `count` is read, so an analytics + * summaries map and the presence read's `metrics` both fit. Undefined + * means the caller hasn't loaded it yet, which the helper treats as + * "not available" so loading shells aren't accidentally lit up. */ export interface InsightInputs { - summaries: Record | undefined; + summaries: Readonly> | undefined; /** Whether the user has logged at least one mood entry. */ hasMood: boolean; /** Whether the user has at least one active medication. */ diff --git a/src/lib/openapi/routes/insights/paths.ts b/src/lib/openapi/routes/insights/paths.ts index d66169878..2bcd08eec 100644 --- a/src/lib/openapi/routes/insights/paths.ts +++ b/src/lib/openapi/routes/insights/paths.ts @@ -22,6 +22,7 @@ import { import { insightsCardsResponse, insightsComprehensiveResponse, + insightsPresenceResponse, metricStatusQuery, metricStatusResponse, biomarkerAssessmentQuery, @@ -126,6 +127,29 @@ export const insightsPaths: NonNullable = { }, }, }, + "/api/insights/presence": { + get: { + tags: ["Insights"], + summary: "Which Insights pages have data", + description: + "What the Insights tab strip gates its pills on, for the record on screen: the reading count per measurement type (after the record's module switches) and whether the record has mood entries in the last 90 days, a tracked scheduled medication, workouts and ECG recordings. A flag whose module is off is false. Counts and flags only; each page reads its own data. No provider is called and no AI switch refuses it. Delegable at MANAGE level over the whole record. Cookie or Bearer auth.", + responses: { + "200": { + description: "The presence answer.", + content: { + "application/json": { + schema: dataEnvelope( + insightsPresenceResponse, + "InsightsPresenceEnvelope", + ), + }, + }, + }, + ...stdResponses, + ...recordRefusal(), + }, + }, + }, "/api/insights/cards": { get: { tags: ["Insights"], diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 8e2baeb9f..cfc928786 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -1691,6 +1691,36 @@ export const ecgListResponse = z "Metadata-only ECG recording list. Never carries the waveform — the per-recording strip is fetched on demand from GET /api/insights/ecg/{id}. Read-only; no LLM call. Reflects only the device's own classification, never a HealthLog interpretation.", }); +export const insightsPresenceResponse = z + .object({ + metrics: z + .record(z.string(), z.object({ count: z.number().int() })) + .describe( + "Keyed by `MeasurementType`: the reading count of every type with at least one reading, after the record's module switches. The same counts `GET /api/analytics?slice=summaries` reports.", + ), + hasMood: z + .boolean() + .describe( + "A mood entry in the last 90 days. False while the mood module is off.", + ), + hasMedication: z + .boolean() + .describe( + "An active medication with a schedule whose intake is tracked. False while the medications module is off.", + ), + hasWorkouts: z + .boolean() + .describe( + "At least one workout. False while the workouts module is off.", + ), + hasEcgRecordings: z.boolean().describe("At least one ECG recording."), + }) + .meta({ + id: "InsightsPresence", + description: + "Which Insights pages have something to show for the record: what the tab strip gates its pills on. Counts and flags only, no values.", + }); + export const ecgDetailQuery = z .object({ full: z diff --git a/src/lib/query-keys/insights.ts b/src/lib/query-keys/insights.ts index d216f4acc..19854e014 100644 --- a/src/lib/query-keys/insights.ts +++ b/src/lib/query-keys/insights.ts @@ -6,6 +6,8 @@ export const insightsKeys = { insightsRoot: () => ["insights"] as const, insightsComprehensive: () => ["insights", "comprehensive"] as const, + /** Which Insights pages have data (`/api/insights/presence`), for the tab strip. */ + insightsPresence: () => ["insights", "presence"] as const, insightsTargets: () => ["insights", "targets"] as const, /** * Shared cache key for the rich `/api/insights/generate` advisor diff --git a/tests/integration/insights-presence.test.ts b/tests/integration/insights-presence.test.ts new file mode 100644 index 000000000..e4d84bdd8 --- /dev/null +++ b/tests/integration/insights-presence.test.ts @@ -0,0 +1,184 @@ +/** + * `GET /api/insights/presence`, against Postgres. + * + * The Insights tab strip gates its pills on this one answer: the reading + * count per type and whether the record has mood entries, a scheduled + * medication, workouts and ECG recordings. It must answer for the record on + * screen, a delegate's switched record included, and read nothing a + * switched-off module hides. Every call goes through the contract check. All + * data invented. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import type { User } from "@/generated/prisma/client"; +import { GET as getPresence } from "@/app/api/insights/presence/route"; +import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; + +import { callRoute, type RouteAuth } from "../kit/call-route"; +import { + makeGrant, + makeMeasurement, + makeMedication, + makeSession, + makeUser, + makeWorkout, +} from "../kit/factories"; +import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup"; + +const prisma = getPrismaClient(); +const DAY_MS = 86_400_000; + +interface PresenceBody { + metrics: Record; + hasMood: boolean; + hasMedication: boolean; + hasWorkouts: boolean; + hasEcgRecordings: boolean; +} + +async function presence(auth: RouteAuth) { + const res = await callRoute(getPresence, { + method: "GET", + route: "/api/insights/presence", + auth, + }); + expect(res.status).toBe(200); + return res.data; +} + +/** Two weight readings, a recent mood, a medication, a workout and an ECG. */ +async function seedEverything(user: Pick) { + for (const daysAgo of [3, 2]) { + await makeMeasurement(user, { + measuredAt: new Date(Date.now() - daysAgo * DAY_MS), + }); + } + const loggedAt = new Date(Date.now() - 5 * DAY_MS); + await prisma.moodEntry.create({ + data: { + userId: user.id, + date: loggedAt.toISOString().slice(0, 10), + mood: "GUT", + score: 4, + moodLoggedAt: loggedAt, + }, + }); + await makeMedication(user); + await makeWorkout(user); + await prisma.ecgRecording.create({ + data: { + userId: user.id, + source: "APPLE_HEALTH", + externalRecordingId: `ecg-${user.id}`, + recordedAt: new Date(Date.now() - DAY_MS), + waveformEncrypted: Buffer.from([0]), + samplingFrequency: 512, + sampleCount: 0, + }, + }); +} + +beforeEach(async () => { + await truncateAllTables(prisma); + __resetAllCachesForTests(); +}); + +describe("GET /api/insights/presence", () => { + it("answers what the record holds", async () => { + const user = await makeUser({ timezone: "Europe/Berlin" }); + await seedEverything(user); + + expect(await presence(await makeSession(user))).toEqual({ + metrics: { WEIGHT: { count: 2 } }, + hasMood: true, + hasMedication: true, + hasWorkouts: true, + hasEcgRecordings: true, + }); + }); + + it("answers nothing for an empty record", async () => { + const user = await makeUser(); + expect(await presence(await makeSession(user))).toEqual({ + metrics: {}, + hasMood: false, + hasMedication: false, + hasWorkouts: false, + hasEcgRecordings: false, + }); + }); + + it("counts no mood older than the overview's 90 days and no medication that expects no dose", async () => { + const user = await makeUser(); + const loggedAt = new Date(Date.now() - 120 * DAY_MS); + await prisma.moodEntry.create({ + data: { + userId: user.id, + date: loggedAt.toISOString().slice(0, 10), + mood: "GUT", + score: 4, + moodLoggedAt: loggedAt, + }, + }); + await makeMedication(user, { asNeeded: true }); + await makeMedication(user, { trackIntake: false }); + await makeMedication(user, {}, []); + + const body = await presence(await makeSession(user)); + expect(body.hasMood).toBe(false); + expect(body.hasMedication).toBe(false); + }); + + it("reads nothing a switched-off module hides", async () => { + const user = await makeUser({ + modulePreferencesJson: { + mood: false, + medications: false, + workouts: false, + }, + }); + await seedEverything(user); + + expect(await presence(await makeSession(user))).toMatchObject({ + metrics: { WEIGHT: { count: 2 } }, + hasMood: false, + hasMedication: false, + hasWorkouts: false, + hasEcgRecordings: true, + }); + }); + + it("answers for the record a delegate has switched to", async () => { + const owner = await makeUser(); + const delegate = await makeUser(); + await seedEverything(owner); + await makeGrant(owner, delegate, { access: "MANAGE" }); + const auth = await makeSession(delegate); + + expect((await presence(auth)).hasMood).toBe(false); + + await switchSessionTo(auth.sessionId, owner.id); + expect(await presence(auth)).toEqual({ + metrics: { WEIGHT: { count: 2 } }, + hasMood: true, + hasMedication: true, + hasWorkouts: true, + hasEcgRecordings: true, + }); + }); + + it("refuses a delegate whose grant only reads", async () => { + const owner = await makeUser(); + const delegate = await makeUser(); + await makeGrant(owner, delegate); + const auth = await makeSession(delegate); + await switchSessionTo(auth.sessionId, owner.id); + + const res = await callRoute(getPresence, { + method: "GET", + route: "/api/insights/presence", + auth, + }); + expect(res.status).toBe(403); + }); +}); From 4105e2c85f1d6e85bea18d1e9874451975d3d572 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 12:58:58 +0100 Subject: [PATCH 04/10] fix(coach): keep the last message clear of the New conversation button Where the composer fills the column (a phone, the drawer, a narrow column), the New conversation button floats above the composer and covered the last message's time and actions at 390 px. The thread now keeps the button's height free below the last message while the button floats, the way the shell clears the Coach button, and drops back to its plain gutter once the button sits beside a wide composer. --- .../baselines/version-stamp-comments.json | 4 ++-- .../__tests__/message-thread.test.tsx | 10 ++++++++ .../insights/coach-panel/message-thread.tsx | 24 +++++++++---------- .../insights/coach-panel/new-chat-fab.tsx | 3 ++- 4 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 10a8c80c5..105c8dc14 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", "count": 2876, - "total": 9164, + "total": 9161, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -925,7 +925,7 @@ "components/insights/coach-panel/guided-questions-card.tsx": 1, "components/insights/coach-panel/guided-questions-machine.ts": 2, "components/insights/coach-panel/memory-note.tsx": 1, - "components/insights/coach-panel/message-thread.tsx": 31, + "components/insights/coach-panel/message-thread.tsx": 28, "components/insights/coach-panel/method-line.tsx": 1, "components/insights/coach-panel/mobile-rail-tray.tsx": 2, "components/insights/coach-panel/read-aloud.tsx": 8, diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index 9c28c40b1..ea843d1aa 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -252,6 +252,16 @@ describe("", () => { ); }); + it("keeps the floating New conversation button's height free below the last message", () => { + const html = render(); + const scroller = + html.match(/]*data-slot="coach-message-thread"[^>]*>/)?.[0] ?? ""; + + expect(scroller).toContain("pb-20"); + // Back to the plain gutter once the button sits beside the composer. + expect(scroller).toContain("@min-[57rem]/coach-thread:pb-6"); + }); + it("folds an older message's provenance into the thought-process row, never a raw key", () => { const html = render(); expect(html).toContain('data-slot="coach-turn-steps-done"'); diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index 941d4c55c..d61fa1c4e 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -640,27 +640,25 @@ export function MessageThread({ one scroll container. */}
*]:mx-auto [&>*]:w-full [&>*]:max-w-2xl", "scroll-smooth", + // Every surface with a thread floats New conversation over its + // bottom edge while the composer fills the column (`NewChatFab`), + // so the last message keeps the button's height free, as the + // shell clears the Coach button. The button moves beside a 54rem + // composer, which the column is at 57rem with its padding. + "pb-20 @min-[57rem]/coach-thread:pb-6", )} > {[ diff --git a/src/components/insights/coach-panel/new-chat-fab.tsx b/src/components/insights/coach-panel/new-chat-fab.tsx index 97fb69c9b..0d94e6379 100644 --- a/src/components/insights/coach-panel/new-chat-fab.tsx +++ b/src/components/insights/coach-panel/new-chat-fab.tsx @@ -16,7 +16,8 @@ import { cn } from "@/lib/utils"; * column is wide enough, it takes the bottom right corner beside the * composer, where the Coach button sits on other pages; where the composer * fills the width (a phone, the drawer, a narrow column), it rises just - * above the composer's top edge so it never covers the field. + * above the composer's top edge so it never covers the field, and the thread + * keeps its height free below the last message. * * The parent is the composer's wrapper and must be `relative` and an * `@container/composer`. From 5533b6edf9db70de630291146c5d5a54902028c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 13:06:33 +0100 Subject: [PATCH 05/10] fix(sheets): ask before a dismissal drops an edit that keeps no seed The illness episode, cycle day, custom metric, target, mood tag, symptom and life event sheets open filled from a record or a fetch, so the body check cannot tell typed input from what was loaded, and a stray tap outside or a swipe dropped the change without asking. Each now keeps what it opened with (the cycle day once its fetch has hydrated the form) and passes whether the fields differ to the sheet's dismissal guard. The custom metric and life event forms report it to their hosts the way the workout form does. The size baselines of the six sheet functions and the cycle sheet file are raised by the few lines the comparison takes. --- .../baselines/source-file-lines.json | 4 +- .../baselines/source-function-lines.json | 14 +- .../__tests__/sheet-seed-dismissal.test.tsx | 156 ++++++++++++++++++ .../custom-metrics/custom-metric-detail.tsx | 3 + .../custom-metrics/custom-metric-form.tsx | 46 ++++-- .../custom-metrics/custom-metric-list.tsx | 3 + src/components/cycle/log-day-sheet.tsx | 7 + src/components/illness/new-episode-sheet.tsx | 51 ++++-- .../mood/manage/tag-editor-sheet.tsx | 16 +- .../symptoms/symptom-edit-sheets.tsx | 10 ++ src/components/targets/target-edit-sheet.tsx | 21 ++- src/components/timeline/life-event-form.tsx | 12 +- src/components/timeline/life-event-sheet.tsx | 6 +- 13 files changed, 303 insertions(+), 46 deletions(-) create mode 100644 src/components/__tests__/sheet-seed-dismissal.test.tsx diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 97e043989..c5cf7adec 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,11 +1,11 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 84, - "total": 93802, + "total": 93809, "entries": { "app/medications/page-client.tsx": 839, "components/charts/mood-chart.tsx": 1166, - "components/cycle/log-day-sheet.tsx": 1639, + "components/cycle/log-day-sheet.tsx": 1646, "components/insights/coach-panel/chat-bubble.tsx": 821, "components/insights/coach-panel/coach-conversation.tsx": 1278, "components/insights/coach-panel/use-coach.ts": 1486, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index f27269eb7..f9f64c814 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": 336, - "total": 109333, + "total": 109378, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -74,14 +74,14 @@ "components/clinician/clinician-view.tsx :: ClinicianView": 223, "components/command-palette/command-palette.tsx :: PaletteBody": 333, "components/custom-metrics/custom-metric-chart.tsx :: CustomMetricChart": 228, - "components/custom-metrics/custom-metric-detail.tsx :: CustomMetricDetail": 277, - "components/custom-metrics/custom-metric-form.tsx :: CustomMetricForm": 210, + "components/custom-metrics/custom-metric-detail.tsx :: CustomMetricDetail": 280, + "components/custom-metrics/custom-metric-form.tsx :: CustomMetricForm": 212, "components/custom-metrics/custom-metric-history-list.tsx :: CustomMetricHistoryList": 298, "components/cycle/bbt-chart.tsx :: BbtChart": 244, "components/cycle/cycle-calendar.tsx :: CycleCalendar": 292, "components/cycle/cycle-settings.tsx :: CycleSettings": 228, "components/cycle/cycle-view.tsx :: CycleView": 403, - "components/cycle/log-day-sheet.tsx :: LogDaySheet": 753, + "components/cycle/log-day-sheet.tsx :: LogDaySheet": 760, "components/daily/today-hero.tsx :: TodayHero": 488, "components/dashboard/dashboard-header.tsx :: DashboardHeader": 215, "components/dashboard/medication-intake-quick-add.tsx :: MedicationIntakeQuickAdd": 383, @@ -98,7 +98,7 @@ "components/documents/documents-view.tsx :: DocumentsView": 303, "components/documents/sources/document-source-picker.tsx :: DocumentSourcePicker": 563, "components/illness/log-day-sheet.tsx :: LogDaySheet": 265, - "components/illness/new-episode-sheet.tsx :: NewEpisodeSheet": 264, + "components/illness/new-episode-sheet.tsx :: NewEpisodeSheet": 278, "components/insights/coach-panel/chat-bubble.tsx :: ChatBubbleImpl": 417, "components/insights/coach-panel/coach-conversation.tsx :: CoachConversation": 1002, "components/insights/coach-panel/coach-input.tsx :: CoachInput": 259, @@ -221,8 +221,8 @@ "components/symptoms/symptom-entry-form.tsx :: SymptomEntryForm": 317, "components/symptoms/symptoms-section.tsx :: SymptomsSection": 205, "components/targets/range-bar.tsx :: RangeBar": 215, - "components/targets/target-edit-sheet.tsx :: TargetEditSheetBody": 473, - "components/timeline/life-event-form.tsx :: LifeEventForm": 281, + "components/targets/target-edit-sheet.tsx :: TargetEditSheetBody": 486, + "components/timeline/life-event-form.tsx :: LifeEventForm": 287, "components/timeline/timeline-chart.tsx :: ChartBody": 236, "components/timeline/timeline-chronicle.tsx :: TimelineChronicle": 246, "components/timeline/timeline-view.tsx :: TimelineView": 478, diff --git a/src/components/__tests__/sheet-seed-dismissal.test.tsx b/src/components/__tests__/sheet-seed-dismissal.test.tsx new file mode 100644 index 000000000..495e62b99 --- /dev/null +++ b/src/components/__tests__/sheet-seed-dismissal.test.tsx @@ -0,0 +1,156 @@ +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +/** + * Edit sheets open filled, so "the body has input" says nothing about them: + * each compares its fields with what it opened with and hands that to the + * sheet's dismissal guard. Opened and untouched, every one of them says + * "nothing to lose", so a tap outside closes it without asking; a sheet the + * guard was never wired to passes nothing at all. + */ + +const sheets: unknown[] = []; + +vi.mock("@/components/ui/responsive-sheet", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/components/ui/responsive-sheet") + >()), + ResponsiveSheet: (props: { dirty?: unknown; children?: ReactNode }) => { + sheets.push(props.dirty); + return <>{props.children}; + }, +})); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true, user: { modules: {} } }), +})); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }), + usePathname: () => "/", + useSearchParams: () => new URLSearchParams(), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { NewEpisodeSheet } from "@/components/illness/new-episode-sheet"; +import { TagEditorSheet } from "@/components/mood/manage/tag-editor-sheet"; +import { + EditSymptomDefinitionSheet, + EditSymptomEventSheet, +} from "@/components/symptoms/symptom-edit-sheets"; +import { TargetEditSheet } from "@/components/targets/target-edit-sheet"; +import { LifeEventSheet } from "@/components/timeline/life-event-sheet"; + +function dirtyOf(node: ReactNode): unknown { + sheets.length = 0; + renderToStaticMarkup( + + {node} + , + ); + return sheets[0]; +} + +const noop = () => {}; + +beforeEach(() => { + sheets.length = 0; +}); + +describe("seeded edit sheets and the dismissal guard", () => { + it.each([ + [ + "an illness episode", + , + ], + [ + "a new illness episode", + , + ], + [ + "a mood tag", + , + ], + [ + "a symptom occurrence", + , + ], + [ + "a symptom", + , + ], + [ + "a target", + , + ], + [ + "a life event", + , + ], + ])("%s opens with nothing to lose", (_name, node) => { + expect(dirtyOf(node)).toBe(false); + }); +}); diff --git a/src/components/custom-metrics/custom-metric-detail.tsx b/src/components/custom-metrics/custom-metric-detail.tsx index bf7c4c2ad..22084c008 100644 --- a/src/components/custom-metrics/custom-metric-detail.tsx +++ b/src/components/custom-metrics/custom-metric-detail.tsx @@ -83,6 +83,7 @@ export function CustomMetricDetail({ const [addFooterEl, setAddFooterEl] = useState(null); const [editOpen, setEditOpen] = useState(false); const [editFooterEl, setEditFooterEl] = useState(null); + const [editDirty, setEditDirty] = useState(false); const { data: metric, @@ -326,6 +327,7 @@ export function CustomMetricDetail({ setEditOpen(false)} + onDirtyChange={setEditDirty} /> ) : null} diff --git a/src/components/custom-metrics/custom-metric-form.tsx b/src/components/custom-metrics/custom-metric-form.tsx index e0cb0a053..af37f237c 100644 --- a/src/components/custom-metrics/custom-metric-form.tsx +++ b/src/components/custom-metrics/custom-metric-form.tsx @@ -1,6 +1,6 @@ "use client"; -import { useId, useState } from "react"; +import { useEffect, useId, useState } from "react"; import { createPortal } from "react-dom"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; @@ -58,6 +58,22 @@ interface CustomMetricFormProps { * bottom-sheet branch can sticky-pin it. */ footerSlot?: HTMLElement | null; + /** Whether the fields differ from what the form opened with. */ + onDirtyChange?: (dirty: boolean) => void; +} + +/** What the form opens with: the edited metric, or an empty definition. */ +function metricFields(existing: CustomMetricDto | undefined) { + const text = (n: number | null | undefined) => (n != null ? String(n) : ""); + return { + name: existing?.name ?? "", + unit: existing?.unit ?? "", + low: text(existing?.targetLow), + high: text(existing?.targetHigh), + decimals: text(existing?.decimals), + description: existing?.description ?? "", + correlationEnabled: existing?.correlationEnabled ?? false, + }; } /** @@ -72,25 +88,27 @@ export function CustomMetricForm({ onSuccess, onCancel, footerSlot, + onDirtyChange, }: CustomMetricFormProps) { const { t } = useTranslations(); const formId = useId(); - const [name, setName] = useState(existing?.name ?? ""); - const [unit, setUnit] = useState(existing?.unit ?? ""); - const [low, setLow] = useState( - existing?.targetLow != null ? String(existing.targetLow) : "", - ); - const [high, setHigh] = useState( - existing?.targetHigh != null ? String(existing.targetHigh) : "", - ); - const [decimals, setDecimals] = useState( - existing?.decimals != null ? String(existing.decimals) : "", - ); - const [description, setDescription] = useState(existing?.description ?? ""); + const [seed] = useState(() => metricFields(existing)); + const [name, setName] = useState(seed.name); + const [unit, setUnit] = useState(seed.unit); + const [low, setLow] = useState(seed.low); + const [high, setHigh] = useState(seed.high); + const [decimals, setDecimals] = useState(seed.decimals); + const [description, setDescription] = useState(seed.description); const [correlationEnabled, setCorrelationEnabled] = useState( - existing?.correlationEnabled ?? false, + seed.correlationEnabled, ); + const fields = { name, unit, low, high, decimals, description }; + const dirty = + JSON.stringify({ ...fields, correlationEnabled }) !== JSON.stringify(seed); + useEffect(() => { + onDirtyChange?.(dirty); + }, [dirty, onDirtyChange]); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const save = useSaveCustomMetric(existing); diff --git a/src/components/custom-metrics/custom-metric-list.tsx b/src/components/custom-metrics/custom-metric-list.tsx index db8f0e8ea..6c50e016f 100644 --- a/src/components/custom-metrics/custom-metric-list.tsx +++ b/src/components/custom-metrics/custom-metric-list.tsx @@ -46,6 +46,7 @@ export function CustomMetricList() { const router = useRouter(); const [addOpen, setAddOpen] = useState(false); const [addFooterEl, setAddFooterEl] = useState(null); + const [addDirty, setAddDirty] = useState(false); const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: queryKeys.customMetrics(), @@ -154,6 +155,7 @@ export function CustomMetricList() { setAddOpen(false)} + onDirtyChange={setAddDirty} /> diff --git a/src/components/cycle/log-day-sheet.tsx b/src/components/cycle/log-day-sheet.tsx index 3dd60fd47..3bff94b92 100644 --- a/src/components/cycle/log-day-sheet.tsx +++ b/src/components/cycle/log-day-sheet.tsx @@ -701,8 +701,10 @@ export function LogDaySheet({ ? `${date}:${dayLog.isSuccess ? "loaded" : "loading"}` : null; const [lastFormKey, setLastFormKey] = useState(null); + const [seed, setSeed] = useState(null); if (open && formKey !== lastFormKey && !dayLog.isLoading) { setLastFormKey(formKey); + setSeed(null); setRowId(dto?.id ?? null); setFlow((dto?.flow ?? null) as FlowLevel | null); setIntermenstrual(dto?.intermenstrualBleeding ?? false); @@ -788,6 +790,10 @@ export function LogDaySheet({ note, symptoms, }; + // The form as hydrated, for the dismissal guard (a Map reads as `{}`). + const snapshot = JSON.stringify([formState, [...symptoms].sort()]); + if (hydrated && seed === null) setSeed(snapshot); + const dirty = hydrated && seed !== null && seed !== snapshot; const saveState = dayLogSaveState({ hydrated, rowId, form: formState }); const currentRowId = saveState.rowId; @@ -860,6 +866,7 @@ export function LogDaySheet({ void; @@ -102,22 +117,35 @@ export function NewEpisodeSheet({ // render keyed on the open transition (React's recommended alternative to a // setState-in-effect). const [wasOpen, setWasOpen] = useState(false); + const [seed, setSeed] = useState(() => episodeForm(editEpisode, today)); if (open && !wasOpen) { + const next = episodeForm(editEpisode, today); setWasOpen(true); - setLabel(editEpisode?.label ?? ""); - setType(editEpisode?.type ?? "INFECTION"); - setLifecycle(editEpisode?.lifecycle ?? "ACUTE"); - setOnset(editEpisode ? editEpisode.onsetAt.slice(0, 10) : today); - setResolved( - editEpisode?.resolvedAt ? editEpisode.resolvedAt.slice(0, 10) : "", - ); - setParentId(editEpisode?.parentConditionId ?? NONE); - setNote(editEpisode?.note ?? ""); - setBodySite(editEpisode?.bodySite ?? ""); - setLaterality(editEpisode?.laterality ?? null); + setSeed(next); + setLabel(next.label); + setType(next.type); + setLifecycle(next.lifecycle); + setOnset(next.onset); + setResolved(next.resolved); + setParentId(next.parentId); + setNote(next.note); + setBodySite(next.bodySite); + setLaterality(next.laterality); } else if (!open && wasOpen) { setWasOpen(false); } + const form = { + label, + type, + lifecycle, + onset, + resolved, + parentId, + note, + bodySite, + laterality, + }; + const dirty = JSON.stringify(form) !== JSON.stringify(seed); const showParent = PARENTABLE.includes(lifecycle); @@ -172,6 +200,7 @@ export function NewEpisodeSheet({ (tag?.icon ?? "Tag"); - const [groupKey, setGroupKey] = useState( - tag?.groupKey ?? initialGroupKey ?? "custom", - ); + const [seed] = useState(() => ({ + label: tag?.label ?? "", + icon: tag?.icon ?? "Tag", + groupKey: tag?.groupKey ?? initialGroupKey ?? "custom", + })); + const [label, setLabel] = useState(seed.label); + const [icon, setIcon] = useState(seed.icon); + const [groupKey, setGroupKey] = useState(seed.groupKey); const [saving, setSaving] = useState(false); + const dirty = + JSON.stringify({ label, icon, groupKey }) !== JSON.stringify(seed); const trimmed = label.trim(); const valid = trimmed.length > 0 && trimmed.length <= LABEL_MAX_LENGTH; @@ -158,6 +163,7 @@ export function TagEditorSheet({ diff --git a/src/components/symptoms/symptom-edit-sheets.tsx b/src/components/symptoms/symptom-edit-sheets.tsx index fe2a44bbc..56b22072d 100644 --- a/src/components/symptoms/symptom-edit-sheets.tsx +++ b/src/components/symptoms/symptom-edit-sheets.tsx @@ -78,6 +78,11 @@ export function EditSymptomEventSheet({ // a link the list cannot show (a deleted episode, the list still loading) // is left alone rather than silently dropped. const initialEpisodeId = effectiveEpisodeId(event.episodeId, episodeOptions); + const dirty = + intensity !== event.intensity || + occurredAt !== toLocalValue(event.occurredAt) || + note !== (event.note ?? "") || + linkedEpisodeId !== initialEpisodeId; const low = t("symptoms.entry.anchorLow"); const high = t("symptoms.entry.anchorHigh"); @@ -107,6 +112,7 @@ export function EditSymptomEventSheet({ onOpenChange={(open) => { if (!open) onClose(); }} + dirty={dirty} title={t("symptoms.section.editEventTitle", { symptom: label })} footer={ <> @@ -222,6 +228,10 @@ export function EditSymptomDefinitionSheet({ onOpenChange={(open) => { if (!open) onClose(); }} + dirty={ + label !== (definition.label ?? "") || + icon !== (definition.icon ?? "Tag") + } title={t("symptoms.section.editSymptomTitle")} footer={ <> diff --git a/src/components/targets/target-edit-sheet.tsx b/src/components/targets/target-edit-sheet.tsx index 890f4b344..b5daf8ff5 100644 --- a/src/components/targets/target-edit-sheet.tsx +++ b/src/components/targets/target-edit-sheet.tsx @@ -212,10 +212,22 @@ function TargetEditSheetBody({ // Display values: user-edited string (if any) wins, otherwise we // surface the freshest seeded value derived from the // `thresholdsData` query + the parent-provided `initialRange`. - const displayMin = minStr ?? String(primary.min); - const displayMax = maxStr ?? String(primary.max); - const displayDiaMin = diaMinStr ?? (secondary ? String(secondary.min) : ""); - const displayDiaMax = diaMaxStr ?? (secondary ? String(secondary.max) : ""); + const seeded = { + min: String(primary.min), + max: String(primary.max), + diaMin: secondary ? String(secondary.min) : "", + diaMax: secondary ? String(secondary.max) : "", + }; + const displayMin = minStr ?? seeded.min; + const displayMax = maxStr ?? seeded.max; + const displayDiaMin = diaMinStr ?? seeded.diaMin; + const displayDiaMax = diaMaxStr ?? seeded.diaMax; + // A bound typed away from its seed; a dismissal then asks first. + const dirty = + displayMin !== seeded.min || + displayMax !== seeded.max || + displayDiaMin !== seeded.diaMin || + displayDiaMax !== seeded.diaMax; // Focus management — the first input gets focus when the sheet // opens, matching the Dashboard chart cog's open-on-focus pattern. @@ -412,6 +424,7 @@ function TargetEditSheetBody({ void; /** Where the action row goes; inline when absent. */ footerSlot?: HTMLElement | null; + /** Whether the fields differ from what the form opened with. */ + onDirtyChange?: (dirty: boolean) => void; } function CategoryChips({ @@ -208,6 +210,7 @@ export function LifeEventForm({ onSuccess, onCancel, footerSlot, + onDirtyChange, }: LifeEventFormProps) { const { t } = useTranslations(); const ids = { @@ -219,9 +222,14 @@ export function LifeEventForm({ note: useId(), }; const today = todayKeyIn(undefined); - const [draft, setDraft] = useState(() => + const [seed] = useState(() => event ? draftFromEvent(event) : emptyDraft(defaultDate ?? today), ); + const [draft, setDraft] = useState(seed); + const dirty = JSON.stringify(draft) !== JSON.stringify(seed); + useEffect(() => { + onDirtyChange?.(dirty); + }, [dirty, onDirtyChange]); const [errors, setErrors] = useState({}); const [saveError, setSaveError] = useState(null); const { create, update, remove } = useLifeEventMutations(); diff --git a/src/components/timeline/life-event-sheet.tsx b/src/components/timeline/life-event-sheet.tsx index ce8cfa5bb..90255bf67 100644 --- a/src/components/timeline/life-event-sheet.tsx +++ b/src/components/timeline/life-event-sheet.tsx @@ -3,7 +3,8 @@ /** * The timeline's own life-event sheet (v1.42, #613): add from the header * button or a readiness link, edit from a life event in the chronicle or the - * selection bar. A bottom sheet on a phone, a dialog above. + * selection bar. A bottom sheet on a phone, a dialog above. A dismissal + * asks first once the form differs from what it opened with. */ import { useState } from "react"; @@ -24,11 +25,13 @@ export function LifeEventSheet({ }) { const { t } = useTranslations(); const [footer, setFooter] = useState(null); + const [formDirty, setFormDirty] = useState(false); const close = () => onOpenChange(false); return ( } @@ -41,6 +44,7 @@ export function LifeEventSheet({ onSuccess={close} onCancel={close} footerSlot={footer} + onDirtyChange={setFormDirty} /> )} From 8f3b640ab68d100c6a695734621abf3641fd6722 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 13:07:54 +0100 Subject: [PATCH 06/10] fix(sheets): put a swiped sheet back when it asks before closing A swipe past the close point paints the sheet where the finger let go and asks the owner to close it. A sheet holding unsaved input asks about discarding first and stays open, so it sat half pushed off the screen behind the question. One frame after the close, a sheet whose content is still open now glides back to its resting place. The function's size baseline rises by the seven lines this takes. --- .../baselines/source-function-lines.json | 4 +-- src/hooks/__tests__/use-sheet-drag.test.ts | 28 +++++++++++++++++++ src/hooks/use-sheet-drag.ts | 7 +++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index f9f64c814..66228b51e 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": 336, - "total": 109378, + "total": 109385, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -231,7 +231,7 @@ "components/vaccinations/custom-vaccine-sheet.tsx :: CustomVaccineSheet": 246, "components/vaccinations/vaccination-form.tsx :: VaccinationForm": 218, "components/workouts/manual-workout-form.tsx :: ManualWorkoutForm": 321, - "hooks/use-sheet-drag.ts :: createSheetDrag": 312, + "hooks/use-sheet-drag.ts :: createSheetDrag": 319, "lib/activity/calendar-math.ts :: buildActivityCalendar": 204, "lib/ai/anthropic-client.ts :: generateCompletion": 294, "lib/ai/coach/cycle-snapshot.ts :: buildCycleSnapshotBlock": 202, diff --git a/src/hooks/__tests__/use-sheet-drag.test.ts b/src/hooks/__tests__/use-sheet-drag.test.ts index 2b88e7e10..be7880307 100644 --- a/src/hooks/__tests__/use-sheet-drag.test.ts +++ b/src/hooks/__tests__/use-sheet-drag.test.ts @@ -23,6 +23,9 @@ interface FakeEl { parentElement: FakeEl | null; overflowY: string; own?: boolean; + /** Radix's `data-state` on the sheet content. */ + state?: "open" | "closed"; + getAttribute: (name: string) => string | null; closest: (selector: string) => FakeEl | null; getAnimations: () => Animation[]; setPointerCapture: (id: number) => void; @@ -39,6 +42,8 @@ function fake(partial: Partial = {}): FakeEl { parentElement: null, overflowY: "visible", closest: () => (node.own ? node : null), + getAttribute: (name) => + name === "data-state" ? (node.state ?? null) : null, getAnimations: () => [], setPointerCapture: vi.fn(), querySelector: () => null, @@ -173,6 +178,29 @@ describe("createSheetDrag", () => { expect(s.controller.swallowClick()).toBe(false); }); + it("returns to its rest when a guarded sheet stays open after the swipe", () => { + const s = setup(); + // The owner asks about unsaved input instead of closing. + s.content.state = "open"; + s.drag(s.header, 140, { stepMs: 40, restMs: 200 }); + expect(s.onClose).toHaveBeenCalledTimes(1); + expect(s.content.style.transform).toBe("translate3d(0, 140px, 0)"); + + s.flushFrames(); + expect(s.content.style.transform).toBe("translate3d(0, 0px, 0)"); + expect(s.overlay.style.opacity).toBe("1"); + s.timers.forEach((run) => run()); + expect(s.content.style.transform).toBe(""); + }); + + it("leaves a closing sheet where it was let go", () => { + const s = setup(); + s.content.state = "closed"; + s.drag(s.header, 140, { stepMs: 40, restMs: 200 }); + s.flushFrames(); + expect(s.content.style.transform).toBe("translate3d(0, 140px, 0)"); + }); + it("lets its own close through: the swallowed click comes after it", () => { const s = setup(); let swallowedDuringClose: boolean | null = null; diff --git a/src/hooks/use-sheet-drag.ts b/src/hooks/use-sheet-drag.ts index fe669b4ad..5a54f427d 100644 --- a/src/hooks/use-sheet-drag.ts +++ b/src/hooks/use-sheet-drag.ts @@ -354,6 +354,13 @@ export function createSheetDrag( // Before the swallow is armed: the close is itself a click. options.onClose(); swallow = true; + // A sheet holding unsaved input asks first and stays open meanwhile; + // put it back where it rests rather than where it was let go. + frame(() => { + if (options.content()?.getAttribute("data-state") === "open") { + settleTo(g, options.snap()); + } + }); return; } swallow = true; From 0a71e8ffa5aff2ac044dfed75783dbac8522b0d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 13:08:45 +0100 Subject: [PATCH 07/10] fix(ui): keep the last four small fields at 16 px on a phone The Coach memory editor, the managed profile's handover link, the cycle length fields and the command palette's search set a font size under 16 px on touch screens, so iOS zoomed the page when one took focus. They now read 16 px on a touch screen and keep their smaller size under a fine pointer, like the field primitives. With no field left over, the guard's list of known offenders goes. --- src/__tests__/pwa-mobile-shell-guard.test.ts | 23 +------------------ .../command-palette/command-palette.tsx | 2 +- src/components/cycle/cycle-settings.tsx | 2 +- .../access/managed-profile-handover.tsx | 2 +- .../settings/coach-memory-section.tsx | 1 - 5 files changed, 4 insertions(+), 26 deletions(-) diff --git a/src/__tests__/pwa-mobile-shell-guard.test.ts b/src/__tests__/pwa-mobile-shell-guard.test.ts index 89f6eebbb..463402abd 100644 --- a/src/__tests__/pwa-mobile-shell-guard.test.ts +++ b/src/__tests__/pwa-mobile-shell-guard.test.ts @@ -304,20 +304,6 @@ function smallFields( } describe("no field call site goes under 16 px on a touch screen", () => { - /** - * Call sites still under 16 px, fixed by a change of their own. An entry - * that stops matching fails, so the list only shrinks. - */ - const OPEN: Readonly> = { - "components/settings/coach-memory-section.tsx": - "/settings/coach memory editor", - "components/settings/access/managed-profile-handover.tsx": - "read-only handover link field", - "components/cycle/cycle-settings.tsx": "raw cycle-length number field", - "components/command-palette/command-palette.tsx": - "search field shrinks at `md:` without `pointer-fine:`", - }; - const scan = productionSources() .filter((rel) => rel.endsWith(".tsx")) .map((rel) => ({ rel, ...smallFields(rel, readSrc(rel)) })); @@ -349,13 +335,6 @@ describe("no field call site goes under 16 px on a touch screen", () => { }); it("no typed-into field sets a size under 16 px outside a fine pointer", () => { - const offenders = scan - .filter((file) => !(file.rel in OPEN)) - .flatMap((file) => file.small); - expect(offenders).toEqual([]); - }); - - it.each(Object.keys(OPEN))("%s is still open", (rel) => { - expect(scan.find((file) => file.rel === rel)?.small ?? []).not.toEqual([]); + expect(scan.flatMap((file) => file.small)).toEqual([]); }); }); diff --git a/src/components/command-palette/command-palette.tsx b/src/components/command-palette/command-palette.tsx index 5f47241d7..59a814f94 100644 --- a/src/components/command-palette/command-palette.tsx +++ b/src/components/command-palette/command-palette.tsx @@ -329,7 +329,7 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) { autoCorrect="off" spellCheck={false} enterKeyHint="go" - className="placeholder:text-muted-foreground h-14 min-w-0 flex-1 bg-transparent text-base outline-none md:text-sm" + className="placeholder:text-muted-foreground h-14 min-w-0 flex-1 bg-transparent text-base outline-none md:pointer-fine:text-sm" /> {isMobile ? (
); diff --git a/src/components/settings/access/managed-profile-handover.tsx b/src/components/settings/access/managed-profile-handover.tsx index 2c9cf6f53..fe6745932 100644 --- a/src/components/settings/access/managed-profile-handover.tsx +++ b/src/components/settings/access/managed-profile-handover.tsx @@ -468,7 +468,7 @@ function HandoverLink({ id="managed-profile-handover-url" readOnly value={link.url} - className="min-w-0 flex-1 font-mono text-xs" + className="min-w-0 flex-1 font-mono md:pointer-fine:text-xs" onFocus={(e) => e.currentTarget.select()} />