diff --git a/.env.production.example b/.env.production.example index c5c87c0e6..a56dbf734 100644 --- a/.env.production.example +++ b/.env.production.example @@ -541,24 +541,6 @@ API_TOKEN_HMAC_KEY="" # FHIR_MAX_MEDICATION_ADMINISTRATIONS="5000" -# ----------------------------------------------------------------------------- -# Dashboard snapshot -- BUILD-TIME toggle (default ON) -# ----------------------------------------------------------------------------- -# The dashboard hydrates every above-the-fold tile from the single -# `GET /api/dashboard/snapshot` cell so the whole strip shares one -# completion moment. This is the default and the desired behaviour. -# -# `NEXT_PUBLIC_*` vars are inlined into the client bundle at BUILD time, -# not read at runtime -- so this has NO effect on the prebuilt GHCR -# image and is intentionally absent from the `docker-compose.yml` -# runtime `environment:` whitelist. To fall back to the legacy -# four-cell path you must rebuild the image with the var set to "false": -# -# $ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build # or a Dockerfile ARG -# -# NEXT_PUBLIC_DASHBOARD_SNAPSHOT="false" - - # ----------------------------------------------------------------------------- # Client-IP resolution -- optional (required behind Cloudflare) # ----------------------------------------------------------------------------- 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/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index 039ecfd43..7bd473110 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -195,26 +195,6 @@ bare Nginx — including the `X-Forwarded-For` chain shape that the rate-limiter trusts and the `TRUST_PROXY_HOPS` env var that pins how many hops to read back. -## Build-time feature toggles - -A small number of toggles are `NEXT_PUBLIC_*` variables: the framework -inlines them into the client bundle at **build time**, so the pre-built -GHCR image cannot read them at runtime. Setting them in `.env` or the -compose `environment:` block has no effect — they only take hold when -you build your own image. They are intentionally absent from the -compose runtime whitelist for that reason. - -| Variable | Default | Effect when set | -| -------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `NEXT_PUBLIC_DASHBOARD_SNAPSHOT` | on | The dashboard hydrates every above-the-fold tile from the single `GET /api/dashboard/snapshot` cell so the whole strip shares one completion moment. Build with `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` to fall back to the legacy four-cell path. | - -To flip one, rebuild the image with the variable exported: - -```bash -NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build -# or pass it as a Dockerfile build ARG / Docker build --build-arg -``` - ## 7. Where to go next | Next step | File | diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts index 0aa90fe5d..51d7ef289 100644 --- a/e2e/dashboard.spec.ts +++ b/e2e/dashboard.spec.ts @@ -21,12 +21,10 @@ test.describe("authenticated dashboard render", () => { test.use({ storageState: STORAGE_STATE_PATH }); test.beforeEach(async ({ page }) => { - // v1.7.2 — the dashboard snapshot flag is default-ON, so the page - // reads every tile from `/api/dashboard/snapshot` and ignores the - // legacy cells below. Mock the snapshot with the same populated - // four-metric set so the tile strip + charts paint. The legacy - // `/api/analytics` + `/api/mood/analytics` mocks stay for the - // reversible `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` path. + // The page reads every tile from `/api/dashboard/snapshot`; mock it + // with the same populated four-metric set so the tile strip + charts + // paint. The dashboard ignores the `/api/analytics` and + // `/api/mood/analytics` mocks below. await mockDashboardSnapshot(page); // Mock analytics with a populated weight summary so the weight tile diff --git a/e2e/utils/mock-dashboard-snapshot.ts b/e2e/utils/mock-dashboard-snapshot.ts index 8e3ae7574..9f3fec2a2 100644 --- a/e2e/utils/mock-dashboard-snapshot.ts +++ b/e2e/utils/mock-dashboard-snapshot.ts @@ -88,20 +88,16 @@ function mockTargetBands(): DashboardTargetBands { /** * v1.7.2 — shared mock for `GET /api/dashboard/snapshot`. * - * The dashboard snapshot flag flipped default-ON in v1.7.2, so * `src/app/page.tsx` reads every above-the-fold tile from the single - * `/api/dashboard/snapshot` cell unless `NEXT_PUBLIC_DASHBOARD_SNAPSHOT` - * is `"false"`. The e2e build sets no such var, so the snapshot path is - * live in CI. Specs that previously mocked only the legacy cells - * (`/api/analytics` + `/api/mood/analytics` + `/api/dashboard/widgets`) - * now hit the REAL snapshot route, which returns empty `summaries` for - * the seed user (zero measurements) and blanks the tile strip. + * `/api/dashboard/snapshot` cell. Without a mock a spec hits the REAL + * snapshot route, which returns empty `summaries` for the seed user (zero + * measurements) and blanks the tile strip. * * `mockDashboardSnapshot` registers a `page.route` for the snapshot - * endpoint returning a correctly-typed `DashboardSnapshot` so the - * default-ON path paints deterministic tiles. Callers keep their legacy - * mocks alongside it — harmless, and they protect the reversible - * `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` path. + * endpoint returning a correctly-typed `DashboardSnapshot` so the tiles + * paint deterministically. Mocks of the older cells (`/api/analytics`, + * `/api/mood/analytics`, `/api/dashboard/widgets`) beside it are unused by + * the dashboard. */ /** Fill a `DataSummary` from a sparse partial, defaulting the rest. */ 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..097316b9d 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": 109383, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -38,7 +38,7 @@ "app/api/record-settings/[family]/route.ts :: apiHandler(arg) #2": 262, "app/api/user/ai-provider/route.ts :: apiHandler(arg) #2": 250, "app/api/withings/callback/route.ts :: apiHandler(arg)": 247, - "app/auth/login/page.tsx :: LoginPage": 569, + "app/auth/login/page.tsx :: LoginPage": 567, "app/auth/register/page.tsx :: RegisterPage": 285, "app/coach/conversations/page.tsx :: CoachConversationsBody": 249, "app/coach/plans/page.tsx :: CoachPlansBody": 216, @@ -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, @@ -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/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index bd8617a0b..8db3dc8c1 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, + "count": 2872, + "total": 9153, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -70,7 +70,6 @@ "__tests__/unit-preference-display-guard.test.ts": 2, "__tests__/v1391-additive-contract.test.ts": 1, "__tests__/write-rate-limit-guard.test.ts": 1, - "app/__tests__/dashboard-analytics-split.test.ts": 2, "app/__tests__/dashboard-chart-reveal.test.tsx": 2, "app/__tests__/dashboard-suspense-boundaries.test.ts": 2, "app/__tests__/dashboard-tile-strip-skeleton.test.ts": 4, @@ -925,7 +924,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, @@ -982,7 +981,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, @@ -1791,7 +1790,6 @@ "lib/analytics/__tests__/effective-range.test.ts": 1, "lib/analytics/__tests__/glucose-panel-coherence.test.ts": 1, "lib/analytics/__tests__/glucose-panel-window.test.ts": 2, - "lib/analytics/__tests__/merge-slim-thick.test.ts": 3, "lib/analytics/__tests__/mood-series.test.ts": 1, "lib/analytics/__tests__/range-delta.test.ts": 1, "lib/analytics/__tests__/resting-pulse.test.ts": 1, @@ -1824,7 +1822,6 @@ "lib/analytics/correlations.ts": 1, "lib/analytics/intraday-pulse-io.ts": 2, "lib/analytics/intraday-pulse.ts": 3, - "lib/analytics/merge-slim-thick.ts": 2, "lib/analytics/mood-series.ts": 2, "lib/analytics/range-delta.ts": 1, "lib/analytics/range-shared.ts": 1, @@ -1929,7 +1926,6 @@ "lib/dashboard/__tests__/meds-today.test.ts": 1, "lib/dashboard/__tests__/score-narrative.test.ts": 1, "lib/dashboard/__tests__/score-rings.test.ts": 1, - "lib/dashboard/__tests__/snapshot-flag.test.ts": 1, "lib/dashboard/__tests__/snapshot.test.ts": 15, "lib/dashboard/__tests__/target-bands.test.ts": 2, "lib/dashboard/bands.ts": 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__/compose-env-whitelist-guard.test.ts b/src/__tests__/compose-env-whitelist-guard.test.ts index 89f9a534e..676aa37f2 100644 --- a/src/__tests__/compose-env-whitelist-guard.test.ts +++ b/src/__tests__/compose-env-whitelist-guard.test.ts @@ -136,10 +136,6 @@ const NOT_OPERATOR_CONFIGURABLE: Readonly> = { "CI-only knob for the same nightly eval workflow", HEALTHLOG_MCP_TOKEN: "read by the local stdio MCP bridge the operator runs on their own machine; the production image strips tsx so it cannot run there", - - // --- A whitelist entry would be obeyed by half the readers. --- - NEXT_PUBLIC_DASHBOARD_SNAPSHOT: - "NEXT_PUBLIC_* is inlined into the client bundle at build time and three of its four readers are client components, so a runtime value would steer the server half only — a split-brain worse than no knob. .env.production.example already documents it as a build-time toggle and says it is deliberately absent from the whitelist; adding it would make that note false.", }; function sourceFiles(): string[] { diff --git a/src/__tests__/openapi-route-coverage-guard.test.ts b/src/__tests__/openapi-route-coverage-guard.test.ts index bafc8d560..6df4b68ff 100644 --- a/src/__tests__/openapi-route-coverage-guard.test.ts +++ b/src/__tests__/openapi-route-coverage-guard.test.ts @@ -339,7 +339,6 @@ const UNPUBLISHED: Readonly> = { "/api/internal/web-vitals": { kind: "internalOps", methods: ["POST"] }, "/api/monitoring/csp-report": { kind: "internalOps", methods: ["POST"] }, "/api/monitoring/glitchtip": { kind: "internalOps", methods: ["POST"] }, - "/api/monitoring/settings": { kind: "internalOps", methods: ["GET"] }, "/api/monitoring/umami-script": { kind: "internalOps", methods: ["GET"] }, "/api/monitoring/umami/{path}": { kind: "internalOps", methods: ["POST"] }, "/api/mcp/oauth/authorize": { 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/__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/__tests__/dashboard-analytics-split.test.ts b/src/app/__tests__/dashboard-analytics-split.test.ts deleted file mode 100644 index f895ff77f..000000000 --- a/src/app/__tests__/dashboard-analytics-split.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { dashboardSource } from "@/__tests__/helpers/dashboard-source"; - -/** - * v1.4.39.2 — dashboard analytics slim/thick split. - * - * Pre-fix the dashboard fired a single `useAnalyticsQuery()` against the - * thick `/api/analytics` envelope; every per-type tile waited on the - * heavy fan-out before paint. Mood and medication tiles arrived first - * (separate routes) and then every other tile arrived as one burst, - * which the maintainer reported as "etwas nervig" in the v1.4.39.1 post-deploy - * trace. - * - * Post-fix the dashboard mounts TWO `useAnalyticsQuery` calls in - * parallel: one for `?slice=summaries` (per-type tile strip) and one - * for the thick envelope (BD-Zielbereich + glucose tiles). The tile - * strip paints from the slim slice as soon as it lands; thick fields - * stream in afterwards. Without this dual-mount the cold-mount UX - * waterfall regresses to the v1.4.39.1 behaviour, so the test pins the - * two call shapes. - * - * v1.7.0 — both calls now carry an `enabled` gate so the unified - * snapshot rollout flag can disable them in favour of a single - * `/api/dashboard/snapshot` cell. With the flag OFF (default) the - * slim/thick split still drives the page, so the protective intent - * holds; the matchers only relaxed to tolerate the `enabled` arg. - */ - -describe("v1.4.39.2 — dashboard analytics slim/thick split", () => { - it("mounts both the slim slice and the thick slice in parallel", () => { - const src = dashboardSource(); - // Slim slice — `?slice=summaries` paints the per-type tile strip. - expect(src).toMatch(/useAnalyticsQuery\(\{\s*\n?\s*slice:\s*"summaries"/); - // Thick slice — the no-slice `useAnalyticsQuery` feeds the - // BD-Zielbereich + glucose tiles. Both must coexist. The thick - // call now opens with `{ enabled:` (no `slice` key). - expect(src).toMatch(/useAnalyticsQuery\(\{\s*\n?\s*enabled:/); - }); - - it("merges slim and thick results so call-sites stay shape-stable", () => { - const src = dashboardSource(); - // The merge contract: slim wins on overlapping fields, thick fills - // in `bpInTargetPct*` and `glucoseByContext`. The text anchors - // protect against a future refactor accidentally dropping one of - // the queries — the `data` object that downstream tile rendering - // consumes must stay shape-stable across both query states. - expect(src).toMatch(/analyticsSlimQuery/); - expect(src).toMatch(/analyticsThickQuery/); - expect(src).toMatch(/bpInTargetPct/); - expect(src).toMatch(/glucoseByContext/); - }); -}); diff --git a/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx b/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx index e9283bba1..0fd3f455a 100644 --- a/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx +++ b/src/app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx @@ -284,12 +284,9 @@ describe("the tile the user actually sees", () => { describe("the gate reaches every feed, not just the default one", () => { it("strips the recovery-owned series from a summaries slice", () => { // `gateSummariesByModules` moved out of the snapshot builder, where it - // was private, so `/api/analytics` can apply the SAME filter. With - // `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` that route feeds the tiles, and - // it filtered nothing at all — so recovery-off plus RMSSD-only still - // painted the tile, on the exact path the helper's docblock promised it - // could not. A gate that only holds on one feed is only as strong as a - // rollout flag. + // was private, so `/api/analytics` can apply the SAME filter: that route + // filtered nothing at all, so recovery-off plus RMSSD-only still reached + // every reader of the slice. const summaries = { HEART_RATE_VARIABILITY: summary(9, 55), HRV_RMSSD: summary(30, 42), diff --git a/src/app/__tests__/dashboard-suspense-boundaries.test.ts b/src/app/__tests__/dashboard-suspense-boundaries.test.ts index 0e20a0e51..10e8afef0 100644 --- a/src/app/__tests__/dashboard-suspense-boundaries.test.ts +++ b/src/app/__tests__/dashboard-suspense-boundaries.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect } from "vitest"; import { dashboardSource } from "@/__tests__/helpers/dashboard-source"; -import { readSource } from "@/__tests__/helpers/source-files"; /** * v1.4.40 — dashboard Suspense boundaries. @@ -57,19 +56,6 @@ describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => { ); }); - it("hoists DASHBOARD_QUERY_OPTS to module scope (audit-M2)", () => { - const src = readSource( - "src/components/dashboard/tiles/use-dashboard-data.ts", - "export function useDashboardData", - ); - // Module-scope declaration — search outside the hook body. Declared - // inside it, every render would create a fresh `{}` reference. - const moduleSliceEnd = src.indexOf("export function useDashboardData"); - expect(moduleSliceEnd).toBeGreaterThan(0); - const moduleSlice = src.slice(0, moduleSliceEnd); - expect(moduleSlice).toMatch(/const\s+DASHBOARD_QUERY_OPTS\s*=\s*\{/); - }); - it("keeps the per-render Intl hour walk off the page (audit-H4 successor)", () => { const src = dashboardSource(); // The audit-H4 `useMemo(getHourForTimeZone, [userTimezone])` pin diff --git a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts index 5e3caab19..83549915a 100644 --- a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts +++ b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts @@ -24,11 +24,9 @@ import { * strip's footprint immediately and swaps to the real tiles the moment * the query resolves. * - * v1.7.0 — the gate now keys off `primaryLoading`, which resolves to - * the snapshot cell under `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=true` and the - * slim analytics cell otherwise. A disabled TanStack query reports - * `isLoading: false`, so keying off the slim query in snapshot mode - * (where it is `enabled: false`) flashed the empty state for the whole + * v1.7.0 — the gate keys off `primaryLoading`, which resolves to the + * snapshot cell. A disabled TanStack query reports `isLoading: false`, so + * keying off any other query flashed the empty state for the whole * snapshot fetch. The pure `resolveDashboardFirstPaintGate` carries the * behaviour; textual pins keep the wiring honest. */ @@ -198,13 +196,13 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => { expect(skeletonBlock?.[0]).toContain(" { + it("derives `primaryLoading` from the snapshot query", () => { const src = dashboardSource(); // v1.16.4 — `!mounted` pins SSR + the hydration render to the // skeleton branch so a late-hydrating boundary can't disagree with // the server HTML (React #418); see `useMounted`. expect(src).toMatch( - /const\s+primaryLoading\s*=\s*!mounted\s*\|\|\s*\(snapshotEnabled\s*\?\s*snapshotQuery\.isLoading\s*:\s*analyticsSlimQuery\.isLoading\);/, + /const\s+primaryLoading\s*=\s*!mounted\s*\|\|\s*snapshotQuery\.isLoading;/, ); }); diff --git a/src/app/__tests__/prefetch-record-identity.test.tsx b/src/app/__tests__/prefetch-record-identity.test.tsx index d73cc01da..21d530a12 100644 --- a/src/app/__tests__/prefetch-record-identity.test.tsx +++ b/src/app/__tests__/prefetch-record-identity.test.tsx @@ -64,9 +64,6 @@ vi.mock("@/lib/modules/gate", () => ({ vi.mock("@/lib/dashboard/snapshot-read", () => ({ readDashboardSnapshotCached: (u: unknown) => readDashboardSnapshotCached(u), })); -vi.mock("@/lib/dashboard/snapshot-flag", () => ({ - isDashboardSnapshotEnabled: () => true, -})); vi.mock("@/lib/dashboard/batch-chart-types", () => ({ computeBatchWindow: () => ({ from: "2026-07-01T00:00:00.000Z", diff --git a/src/app/api/__tests__/ai-route-inventory.ts b/src/app/api/__tests__/ai-route-inventory.ts index ce1c0d90d..282910819 100644 --- a/src/app/api/__tests__/ai-route-inventory.ts +++ b/src/app/api/__tests__/ai-route-inventory.ts @@ -240,6 +240,8 @@ export const DATA_ROUTES: Readonly> = { "Lab deltas, owned by the labs module.", "src/app/api/insights/health-status/route.ts": "Baseline drift, computed over core vitals.", + "src/app/api/insights/presence/route.ts": + "Which Insights pages have data: counts and flags, no model text.", "src/app/api/insights/pulse/intraday/route.ts": "The day's pulse shape, a core vital.", "src/app/api/insights/patterns/route.ts": "Stored correlation statistics.", diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index 5c0dff21e..dce76d978 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -350,6 +350,9 @@ const EXEMPT_ROUTES: ReadonlyArray = [ "src/app/api/insights/correlations/route.ts", "src/app/api/insights/coach-read/route.ts", "src/app/api/insights/health-status/route.ts", + // The tab strip's gate spans every module: each flag whose page a + // switched-off module hides answers false and its rows are not read. + "src/app/api/insights/presence/route.ts", "src/app/api/insights/pulse/intraday/route.ts", "src/app/api/insights/patterns/route.ts", "src/app/api/insights/patterns/[id]/route.ts", diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 41dedd7a6..e04de5d20 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -80,11 +80,8 @@ export const GET = apiHandler(async (request?: Request) => { const slim = await readSummariesSlice(user.id, { serveStale: true }); // Drop the types whose module the user turned off, from the SAME map // the snapshot builder gates on (`SUMMARY_TYPE_MODULE`, via - // `gateSummariesByModules`). With `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` - // this slice — not the snapshot — feeds the dashboard tile strip, and - // it used to hand back every type regardless: recovery off plus an - // RMSSD-only account still painted the HRV tile, because nothing on - // this path had ever filtered. + // `gateSummariesByModules`), so a switched-off module's series never + // reaches a page that reads this slice. // // Gated AFTER the cache read on purpose. The cached cell stays // module-independent, so toggling a module takes effect on the next 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/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/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/src/app/api/monitoring/settings/route.ts b/src/app/api/monitoring/settings/route.ts deleted file mode 100644 index bf03de6ef..000000000 --- a/src/app/api/monitoring/settings/route.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { apiHandler } from "@/lib/api-handler"; -import { annotate } from "@/lib/logging/context"; -import { apiSuccess } from "@/lib/api-response"; -import { getPublicMonitoringSettings } from "@/lib/monitoring-settings"; - -export const dynamic = "force-dynamic"; - -export const GET = apiHandler(async () => { - annotate({ action: { name: "monitoring.settings.get" } }); - - const settings = await getPublicMonitoringSettings(); - return apiSuccess({ - umamiEnabled: settings.umamiEnabled, - umamiWebsiteId: settings.umamiWebsiteId, - glitchtipEnabled: settings.glitchtipEnabled, - }); -}); diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 49b44b786..aaa74ab3b 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -21,7 +21,6 @@ import { } from "@/lib/api/api-fetch"; import { MfaLoginStep, type MfaMethod } from "@/components/auth/mfa-login-step"; import { OIDC_MFA_COOKIE, OIDC_MFA_COOKIE_PATH } from "@/lib/auth/oidc-cookie"; -import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister"; import { sanitizeSameOriginPath } from "@/lib/url-safety"; @@ -237,9 +236,7 @@ export default function LoginPage() { // ample headroom to land before the new snapshot is written. void clearOfflineCachesForSessionEnd(); const target = getRedirectTarget(); - if (target === "/" && isDashboardSnapshotEnabled()) { - prefetchDashboardSnapshot(queryClient); - } + if (target === "/") prefetchDashboardSnapshot(queryClient); router.push(target); } diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index b162629dc..d8240b006 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -84,7 +84,6 @@ export default function DashboardPageClient({ const dashboard = useDashboardData({ isAuthenticated, mounted, - user, batchWindowProp: batchWindow, }); const { digestQuery, layout, primaryLoading } = dashboard; diff --git a/src/app/page.tsx b/src/app/page.tsx index bda704c49..047cb9f5f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -6,7 +6,6 @@ import { import { getUnswitchedSession } from "@/lib/auth/acting-carrier"; import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read"; -import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; import { computeBatchWindow, deriveBatchChartTypes, @@ -116,29 +115,18 @@ export default async function DashboardPage() { }); } - // Above-the-fold prefetch runs ONLY in snapshot mode: legacy mode derives - // its chart-type list from analytics queries the RSC did not run, so there - // is no byte-identical key to dehydrate the series under. In legacy mode - // `batchWindow` stays undefined and the client falls back to its own - // browser-local window exactly as before — no behaviour change off-flag. - const snapshotMode = isDashboardSnapshotEnabled(); + // The profile-tz window is computed ONCE and threaded to the client so + // the batched-series key matches byte-for-byte. + batchWindow = computeBatchWindow(new Date(), user.timezone); - // The profile-tz window is computed ONCE (snapshot mode only) and threaded - // to the client so the batched-series key matches byte-for-byte. - if (snapshotMode) { - batchWindow = computeBatchWindow(new Date(), user.timezone); - } - - const seriesTypes = snapshotMode - ? deriveBatchChartTypes( - resolveDashboardLayout(wireBody.layout), - ( - wireBody.tiles as { - summaries?: Record; - } - )?.summaries, - ) - : []; + const seriesTypes = deriveBatchChartTypes( + resolveDashboardLayout(wireBody.layout), + ( + wireBody.tiles as { + summaries?: Record; + } + )?.summaries, + ); // Digest carries a soft budget (cold-day extras rebuild); series is a // warm rollup read (< 100 ms) with none. Both fail soft to `undefined`. 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/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 ? (