From 12e422c25ec84714bd1ed22fcde289cd7f71c529 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 11:25:11 +0200 Subject: [PATCH] Lighten the client shell and finish four client surfaces - Load each settings section on its own: the section route imported all twenty-one statically. A preload starts the chunk beside the record gate's read, and the loading state paints the settings card shell. /settings/[section] goes from 611 to 403 KB gz. - Take Zod out of the shell every route shares: the account-access parse run on each boot is plain TypeScript now, held to the published schema by an equivalence test over good and broken blocks. The canonical unit table moves to its own module, so the display transform no longer pulls Zod in either. Layout entry 199 to 136 KB gz; /insights 369 to 304, /medications 428 to 365, /labs 400 to 337. - Open a saved Coach conversation at its last answer: an instant jump instead of the smooth scroll that lost the end, then follow late layout (charts, tables) for a few seconds until the reader scrolls. - Show the document archive as a month grid of preview tiles, two across on a phone and three or four on a desktop, with the month headings kept. - Add the admin card for the measurement table maintenance: a status read on the maintenance route, one calm confirmation, and the run's state while it is queued, running, finished or stopped. - Re-base the bundle budget: /insights at 304, /settings/[section] watched at 403, aggregate cap 4335 with the reason written down. --- bundle-budget.json | 9 +- docs/api/openapi.yaml | 115 ++++++++ e2e/coach-open-at-end.spec.ts | 206 +++++++++++++++ e2e/documents.spec.ts | 51 ++++ messages/de.json | 28 ++ messages/en.json | 28 ++ messages/es.json | 28 ++ messages/fr.json | 28 ++ messages/it.json | 28 ++ messages/ko.json | 28 ++ messages/pl.json | 28 ++ .../error-level-discipline-guard.test.ts | 11 + .../success-affordance-guard.test.ts | 5 + src/app/admin/[section]/renderer.tsx | 5 +- .../measurements/__tests__/status.test.ts | 143 ++++++++++ .../admin/maintenance/measurements/route.ts | 18 ++ src/app/settings/[section]/page.tsx | 86 ++---- .../admin/measurement-maintenance-card.tsx | 247 ++++++++++++++++++ .../__tests__/document-card.test.tsx | 37 +++ .../__tests__/document-month-grid.test.tsx | 52 ++++ src/components/documents/document-card.tsx | 179 ++++++++----- .../documents/document-month-grid.tsx | 92 +++++++ .../documents/document-timeline.tsx | 74 +++--- src/components/documents/documents-view.tsx | 13 +- .../coach-panel/__tests__/open-at-end.test.ts | 144 ++++++++++ .../insights/coach-panel/message-thread.tsx | 17 ++ .../insights/coach-panel/open-at-end.ts | 102 ++++++++ .../settings/settings-section-loader.tsx | 193 ++++++++++++++ src/hooks/use-auth.ts | 2 +- .../jobs/measurement-maintenance-status.ts | 129 +++++++++ src/lib/measurements/display-transform.ts | 2 +- src/lib/measurements/unit-map.ts | 163 ++++++++++++ src/lib/openapi/routes/admin.ts | 61 +++++ src/lib/query-keys/admin.ts | 2 + ...a.test.ts => account-access-parse.test.ts} | 111 +++++++- src/lib/sharing/account-access-parse.ts | 212 +++++++++++++++ src/lib/sharing/account-access-schema.ts | 79 +----- src/lib/validations/measurement.ts | 155 +---------- 38 files changed, 2505 insertions(+), 406 deletions(-) create mode 100644 e2e/coach-open-at-end.spec.ts create mode 100644 src/app/api/admin/maintenance/measurements/__tests__/status.test.ts create mode 100644 src/components/admin/measurement-maintenance-card.tsx create mode 100644 src/components/documents/__tests__/document-month-grid.test.tsx create mode 100644 src/components/documents/document-month-grid.tsx create mode 100644 src/components/insights/coach-panel/__tests__/open-at-end.test.ts create mode 100644 src/components/insights/coach-panel/open-at-end.ts create mode 100644 src/components/settings/settings-section-loader.tsx create mode 100644 src/lib/jobs/measurement-maintenance-status.ts create mode 100644 src/lib/measurements/unit-map.ts rename src/lib/sharing/__tests__/{account-access-schema.test.ts => account-access-parse.test.ts} (66%) create mode 100644 src/lib/sharing/account-access-parse.ts diff --git a/bundle-budget.json b/bundle-budget.json index cf6e6b7e4..9cb56d61d 100644 --- a/bundle-budget.json +++ b/bundle-budget.json @@ -1,12 +1,13 @@ { - "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /insights and /measurements sat within a kilobyte of theirs and / and /insights/mood within one of theirs; e2e run 34043772954 failed on `/insights/page: 445 KB gz exceeds the 445 KB budget` with the other two at 459 of 460, and none of it was a regression anyone had made. A ceiling a route is already touching does not measure the route, it measures whichever dependency bump landed that week. The file states the measured value per route now, plus one drift allowance over it: measured on a local arm64 build at d48bbff01 as / 447, /insights 434, /measurements 433, /insights/mood 447, with a 20 KB allowance, so the effective ceilings are 467 / 454 / 453 / 467 — a raise for all four. The allowance covers the runner delta (the CI runner measures each route a little heavier than a local arm64 build) and ordinary week-to-week drift, while the step change the gate exists for — a statically imported message catalog, a duplicated chart library — is several times larger and still fails. Every run now prints the delta against the baseline, so a route that has crept 15 KB is legible in the log long before it is red; the flat ceiling could only ever say pass or fail. Re-base by re-measuring on a build with nothing else in it and writing the new numbers here with the reason, exactly as a raised ceiling was written before. Raised to 3724 at v1.39.4 on a measured delta, not a feature. The release branch alone measured 3699 KB gz (3698.9) against the 3700 cap, so the ordinary drift room the v1.37.32 raise left had been used up by the releases since; the review fixes on top of it added 1.3 KB gz across the whole build (3700.2), almost all of it new copy in the seven lazy locale catalogs (the total and reused-table labels, the step and chip wording) plus the result-panel states. The four eager routes measured the same on both builds (/ 458, /insights/mood 456, /insights 445, /measurements 442), all inside their ceilings. The aggregate cap moves from 3700 to 3724, the same 24 KB headroom as the re-measurements before it. Raised to 3749 at v1.39.9 on a measured delta. Main measured 3718 KB gz after v1.39.7 and v1.39.8 (unit-aware copy, the phone layouts, the time-zone work and the account-settings restore labels, almost all of it new strings in the seven lazy locale catalogs), leaving 6 KB of the v1.39.4 headroom; the manual workout entry (#1085: the form, its sheet, the delete action and their copy in seven locales) adds about 7 KB and measured 3725. The four eager routes stay inside their drift allowances (/ +12.5, /insights +12.1, /insights/mood +10.2 KB of 20 on main). The aggregate cap moves from 3724 to 3749, the same 24 KB headroom as the re-measurements before it. Raised to 3790 at v1.40.0 on a measured delta. With the background document reads on main (#1103: the run polling hook, the still-reading states and their copy in seven locales), the key backup step and the plain-http sign-in notice (#1104) and the v1.40 fixes (#1106: the duration reader, the marker unit lock, the chart window) each branch measured 3752 KB gz on its own against the 3749 cap; together they add a few KB more. The four eager routes stay inside their drift allowances (/ +18.4, /insights +16.5, /measurements +14.9, /insights/mood +14.7 KB of 20). The aggregate cap moves from 3749 to 3790, about 24 KB above the combined build, the same headroom as the re-measurements before it. Re-based at v1.40.0 when the quick-entry forms began loading on demand. Custom symptoms added a fifth quick-entry form, and with all five forms bundled eagerly every one of the four eager routes measured over its ceiling (/ 472, /insights 457, /insights/mood 468, /measurements 454 KB gz, each 21 to 25 KB over the baseline). Loading the forms on demand brings them to / 429, /insights 368, /insights/mood 379, /measurements 411: 43 to 90 KB less on first load. The four route baselines move down to those measured values, so the gain is protected by the same 20 KB allowance rather than spent. The aggregate moves the other way, 3774 to 4077, for the reason described at v1.37.27: the aggregate counts each emitted chunk once per route group, and on-demand form chunks land in every route group that can open a quick-entry sheet. Bundling the five forms into one shared on-demand module measured the same (4078), so the split is not the cause. The aggregate cap moves from 3790 to 4100. Bytes on disk rose; bytes a reader downloads fell on every eager route. Raised to 4155 at v1.40.2 on a measured delta. The Coach redesign (the right conversation panel, its row menu and the quick-settings overlay, all loaded on demand), the answer action row, the dashboard's Today overview, the document procedure filter and their copy in seven locales took the whole-build sum from 4080 to 4129 KB gz; most of it is new strings in the seven lazy locale catalogs and the on-demand Coach chunks, which a first paint does not load. The four eager routes stay well inside their drift allowances (/ +1.9, /measurements +1.9, /insights/mood +1.4, /insights +1.0 KB of 20). The aggregate cap moves from 4100 to 4155, about 26 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4182 at v1.42.0 on a measured delta. Main measured 4146 KB gz (lazy catalogs 1177, other chunks 2969); the release branch's shared contract change measured 4158 (lazy catalogs 1188, other chunks 2969): the whole 12 KB is new strings in the seven lazy locale catalogs (the Health Connect import card, air quality and pollen, the managed-profile handover and claim, own vaccines, the lab conversion note, the per-type Apple Health list), and no eager route moved (/ +3.6, /measurements +1.5, /insights/mood +1.1, /insights +0.7 KB of 20). The aggregate cap moves from 4155 to 4182, about 24 KB above the build, the same headroom as before; per-route baselines are unchanged.", + "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /insights and /measurements sat within a kilobyte of theirs and / and /insights/mood within one of theirs; e2e run 34043772954 failed on `/insights/page: 445 KB gz exceeds the 445 KB budget` with the other two at 459 of 460, and none of it was a regression anyone had made. A ceiling a route is already touching does not measure the route, it measures whichever dependency bump landed that week. The file states the measured value per route now, plus one drift allowance over it: measured on a local arm64 build at d48bbff01 as / 447, /insights 434, /measurements 433, /insights/mood 447, with a 20 KB allowance, so the effective ceilings are 467 / 454 / 453 / 467 — a raise for all four. The allowance covers the runner delta (the CI runner measures each route a little heavier than a local arm64 build) and ordinary week-to-week drift, while the step change the gate exists for — a statically imported message catalog, a duplicated chart library — is several times larger and still fails. Every run now prints the delta against the baseline, so a route that has crept 15 KB is legible in the log long before it is red; the flat ceiling could only ever say pass or fail. Re-base by re-measuring on a build with nothing else in it and writing the new numbers here with the reason, exactly as a raised ceiling was written before. Raised to 3724 at v1.39.4 on a measured delta, not a feature. The release branch alone measured 3699 KB gz (3698.9) against the 3700 cap, so the ordinary drift room the v1.37.32 raise left had been used up by the releases since; the review fixes on top of it added 1.3 KB gz across the whole build (3700.2), almost all of it new copy in the seven lazy locale catalogs (the total and reused-table labels, the step and chip wording) plus the result-panel states. The four eager routes measured the same on both builds (/ 458, /insights/mood 456, /insights 445, /measurements 442), all inside their ceilings. The aggregate cap moves from 3700 to 3724, the same 24 KB headroom as the re-measurements before it. Raised to 3749 at v1.39.9 on a measured delta. Main measured 3718 KB gz after v1.39.7 and v1.39.8 (unit-aware copy, the phone layouts, the time-zone work and the account-settings restore labels, almost all of it new strings in the seven lazy locale catalogs), leaving 6 KB of the v1.39.4 headroom; the manual workout entry (#1085: the form, its sheet, the delete action and their copy in seven locales) adds about 7 KB and measured 3725. The four eager routes stay inside their drift allowances (/ +12.5, /insights +12.1, /insights/mood +10.2 KB of 20 on main). The aggregate cap moves from 3724 to 3749, the same 24 KB headroom as the re-measurements before it. Raised to 3790 at v1.40.0 on a measured delta. With the background document reads on main (#1103: the run polling hook, the still-reading states and their copy in seven locales), the key backup step and the plain-http sign-in notice (#1104) and the v1.40 fixes (#1106: the duration reader, the marker unit lock, the chart window) each branch measured 3752 KB gz on its own against the 3749 cap; together they add a few KB more. The four eager routes stay inside their drift allowances (/ +18.4, /insights +16.5, /measurements +14.9, /insights/mood +14.7 KB of 20). The aggregate cap moves from 3749 to 3790, about 24 KB above the combined build, the same headroom as the re-measurements before it. Re-based at v1.40.0 when the quick-entry forms began loading on demand. Custom symptoms added a fifth quick-entry form, and with all five forms bundled eagerly every one of the four eager routes measured over its ceiling (/ 472, /insights 457, /insights/mood 468, /measurements 454 KB gz, each 21 to 25 KB over the baseline). Loading the forms on demand brings them to / 429, /insights 368, /insights/mood 379, /measurements 411: 43 to 90 KB less on first load. The four route baselines move down to those measured values, so the gain is protected by the same 20 KB allowance rather than spent. The aggregate moves the other way, 3774 to 4077, for the reason described at v1.37.27: the aggregate counts each emitted chunk once per route group, and on-demand form chunks land in every route group that can open a quick-entry sheet. Bundling the five forms into one shared on-demand module measured the same (4078), so the split is not the cause. The aggregate cap moves from 3790 to 4100. Bytes on disk rose; bytes a reader downloads fell on every eager route. Raised to 4155 at v1.40.2 on a measured delta. The Coach redesign (the right conversation panel, its row menu and the quick-settings overlay, all loaded on demand), the answer action row, the dashboard's Today overview, the document procedure filter and their copy in seven locales took the whole-build sum from 4080 to 4129 KB gz; most of it is new strings in the seven lazy locale catalogs and the on-demand Coach chunks, which a first paint does not load. The four eager routes stay well inside their drift allowances (/ +1.9, /measurements +1.9, /insights/mood +1.4, /insights +1.0 KB of 20). The aggregate cap moves from 4100 to 4155, about 26 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4182 at v1.42.0 on a measured delta. Main measured 4146 KB gz (lazy catalogs 1177, other chunks 2969); the release branch's shared contract change measured 4158 (lazy catalogs 1188, other chunks 2969): the whole 12 KB is new strings in the seven lazy locale catalogs (the Health Connect import card, air quality and pollen, the managed-profile handover and claim, own vaccines, the lab conversion note, the per-type Apple Health list), and no eager route moved (/ +3.6, /measurements +1.5, /insights/mood +1.1, /insights +0.7 KB of 20). The aggregate cap moves from 4155 to 4182, about 24 KB above the build, the same headroom as before; per-route baselines are unchanged. Re-based at v1.42.0 when the settings sections and Zod left the shared bundles. The settings section route imported all twenty-one sections statically; each now loads on its own, and /settings/[section] measured 611 KB gz before and 403 after. The account-access parse that runs on every boot was the shell's only Zod import, about 63 KB gz on every route; it is plain TypeScript now, held to the published schema by an equivalence test, and the layout entry fell from 199 to 136 KB gz. The canonical unit table moved out of the measurement validation module, which took Zod off the routes whose only reason for it was the display transform: /insights 369 to 304, /medications 428 to 365, /labs 400 to 337. Across the 627 client routes 62 fell, none grew by more than 2 KB, and the per-route sum fell by 1844 KB gz. The /insights baseline moves down to 304 and /settings/[section] joins the watched routes at 403, so both gains are held by the same 20 KB allowance rather than spent. The aggregate moves the other way, 4167 to 4311, for the reason described at v1.37.27 and v1.40.0: the aggregate counts each emitted chunk once, and modules that used to sit in one shared chunk are now emitted into each route group that still needs them (the measurement validation module and the unit table into about twenty-five each, the settings sections into their own chunks). Bytes on disk rose; bytes a reader downloads fell on every route that moved. The aggregate cap moves from 4182 to 4335, about 24 KB above the build, the same headroom as before.", "routeBaselineKbGz": { "/page": 429, - "/insights/page": 368, + "/insights/page": 304, "/measurements/page": 411, - "/insights/mood/page": 379 + "/insights/mood/page": 379, + "/settings/[section]/page": 403 }, "routeDriftAllowanceKbGz": 20, - "totalClientKbGz": 4182, + "totalClientKbGz": 4335, "maxRechartsChunks": 1 } diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e8fffb7d4..f4edad6c9 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -11106,6 +11106,23 @@ paths: "422": *a4 "429": *a5 /api/admin/maintenance/measurements: + get: + tags: + - Admin + summary: Where measurement table maintenance stands + description: "The newest maintenance run and its state, whether the compaction-tombstone purge is still at work, and the + table's size right now. The admin card polls it while a run is queued or running. Cookie auth only: + `requireAdmin()` refuses every Bearer caller." + responses: + "200": + description: The status. + content: + application/json: + schema: + $ref: "#/components/schemas/AdminMeasurementMaintenanceStatusResponse" + "401": *a1 + "422": *a4 + "429": *a5 post: tags: - Admin @@ -57685,6 +57702,104 @@ components: - data - error additionalProperties: false + AdminMeasurementMaintenanceStatusResponse: + type: object + properties: + data: + $ref: "#/components/schemas/AdminMeasurementMaintenanceStatus" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + AdminMeasurementMaintenanceStatus: + type: object + properties: + available: + type: boolean + description: False when there is no background queue to ask (a web-only deployment); nothing can be started then. + purgePending: + type: boolean + description: The compaction-tombstone purge is queued or running. A run started now finishes at once with + `refused_purge_running`. + run: + anyOf: + - type: object + properties: + state: + type: string + enum: + - queued + - running + - completed + - failed + requestedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + startedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + - type: "null" + finishedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + - type: "null" + outcome: + anyOf: + - type: string + enum: + - completed + - refused_purge_running + - already_running + - type: "null" + description: "How a `completed` run ended: `completed` did the work, the two refusals finished without touching the + table. Null for any other state." + required: + - state + - requestedAt + - startedAt + - finishedAt + - outcome + additionalProperties: false + - type: "null" + description: The newest run pg-boss still keeps (terminal rows live seven days), or null when there is none. + sizes: + anyOf: + - type: object + properties: + tableBytes: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: "`measurements` with its indexes and TOAST." + indexBytes: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Its indexes alone. + required: + - tableBytes + - indexBytes + additionalProperties: false + - type: "null" + required: + - available + - purgePending + - run + - sizes + additionalProperties: false AdminMeasurementMaintenanceResponse: type: object properties: diff --git a/e2e/coach-open-at-end.spec.ts b/e2e/coach-open-at-end.spec.ts new file mode 100644 index 000000000..ef3ef4fd1 --- /dev/null +++ b/e2e/coach-open-at-end.spec.ts @@ -0,0 +1,206 @@ +import type { Page, Route } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; + +/** + * A saved conversation opens at its last answer, and stays there after that + * answer's chart arrives. + * + * Before: on a wide screen the thread opened at the top (scroll 0), and on a + * phone it stopped about 70 px short of the end, because the answer's chart + * loaded after the jump. The conversation here is long enough to scroll on + * both widths, and the stored table under the last answer is served late on + * purpose, so the end moves after the thread first paints. + */ + +const CONVERSATION_ID = "coach-open-at-end-e2e"; +const LAST_ID = "coach-open-at-end-last"; + +const TABLE = { + ref: "r1", + source: { + tool: "get_metric_table", + domain: "pulse", + window: "last30days", + period: "current", + granularity: "day", + }, + shape: "timeSeries", + titleKey: "coach.result.title.byDay", + title: "Pulse by day", + rowCount: 14, + chartKind: "line", + displayed: true, + columns: [ + { + key: "day", + kind: "period", + labelKey: "coach.result.column.day", + label: "Day", + }, + { + key: "pulse", + kind: "number", + labelKey: "coach.result.column.value", + label: "Pulse", + unit: "bpm", + decimals: 0, + }, + ], + rows: Array.from({ length: 14 }, (_, i) => [ + new Date(Date.UTC(2026, 8, 10 + i)).toISOString().slice(0, 10), + 60 + (i % 5), + ]), + truncated: false, + chart: { kind: "line", x: "day", series: ["pulse"] }, +}; + +const PARAGRAPH = + "Your resting pulse held steady across the fortnight, with the usual small dip after the longer nights and a slight rise on the two days you logged a cold."; + +function messages() { + const out: Array> = []; + for (let i = 0; i < 8; i += 1) { + const at = `2026-09-24T09:${String(i * 2).padStart(2, "0")}:00.000Z`; + out.push({ + id: `u-${i}`, + role: "user", + content: `Question ${i + 1}: how did my pulse look?`, + createdAt: at, + metricSource: null, + providerType: null, + promptVersion: null, + tokensUsed: null, + model: null, + }); + const last = i === 7; + out.push({ + id: last ? LAST_ID : `a-${i}`, + role: "assistant", + content: `${PARAGRAPH} ${PARAGRAPH}`, + createdAt: at, + metricSource: last + ? { + windows: ["last30days"], + metrics: ["pulse"], + results: [ + { + ref: TABLE.ref, + source: TABLE.source, + shape: TABLE.shape, + titleKey: TABLE.titleKey, + title: TABLE.title, + rowCount: TABLE.rowCount, + chartKind: TABLE.chartKind, + displayed: TABLE.displayed, + }, + ], + } + : null, + providerType: "mock", + promptVersion: "e2e", + tokensUsed: 42, + model: "mock", + }); + } + return out; +} + +function fulfilJson(route: Route, data: unknown) { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data, error: null }), + }); +} + +async function mockConversation(page: Page) { + await page.route( + /\/api\/insights\/chat(?:\/[^?]+)?(?:\?.*)?$/, + async (route) => { + const url = new URL(route.request().url()); + if ( + url.pathname === + `/api/insights/chat/${CONVERSATION_ID}/messages/${LAST_ID}/results` + ) { + // Late on purpose: the chart lands after the thread has painted. + await new Promise((resolve) => setTimeout(resolve, 900)); + return fulfilJson(route, { results: [TABLE] }); + } + if (url.pathname.startsWith(`/api/insights/chat/${CONVERSATION_ID}/`)) { + return fulfilJson(route, { results: [] }); + } + if (url.pathname === `/api/insights/chat/${CONVERSATION_ID}`) { + return fulfilJson(route, { + id: CONVERSATION_ID, + title: "Pulse over two weeks", + createdAt: "2026-09-24T09:00:00.000Z", + updatedAt: "2026-09-24T09:14:00.000Z", + messageCount: 16, + fenced: false, + attachments: [], + documentTitle: null, + attachmentCount: 0, + summary: null, + messages: messages(), + }); + } + return fulfilJson(route, { conversations: [], nextCursor: null }); + }, + ); + await page.route("**/api/insights/coach/nudge-status*", (route) => + fulfilJson(route, { nudgedAt: null, unread: false }), + ); + await page.route("**/api/coach/about-me/questions*", (route) => + fulfilJson(route, { questions: [] }), + ); +} + +/** Distance from the thread's current view to its end, in px. */ +async function distanceFromEnd(page: Page): Promise { + return page + .locator('[data-slot="coach-message-thread"]') + .evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight); +} + +test.describe("Coach: a saved conversation opens at its end", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeEach(async ({ page }) => { + await serveAiBlock(page, aiBlockAvailable()); + await mockConversation(page); + }); + + for (const width of [390, 1440] as const) { + test(`at ${width} px it lands on the last answer and stays after its chart loads`, async ({ + page, + }) => { + await page.setViewportSize({ + width, + height: width === 390 ? 844 : 900, + }); + await page.goto(`/coach?c=${CONVERSATION_ID}`, { + waitUntil: "domcontentloaded", + }); + + const thread = page.locator('[data-slot="coach-message-thread"]'); + await expect(thread).toBeVisible({ timeout: 15_000 }); + // The thread scrolls at all: otherwise "at the end" proves nothing. + expect( + await thread.evaluate((el) => el.scrollHeight > el.clientHeight + 200), + ).toBe(true); + + const chart = page.locator( + '[data-slot="coach-result-chart"][data-ref="r1"]', + ); + await expect(chart).toBeVisible({ timeout: 15_000 }); + // The chart grew the thread after the first jump; the view followed. + await expect.poll(() => distanceFromEnd(page)).toBeLessThanOrEqual(2); + await expect( + page.locator(`[data-slot="coach-assistant-turn"]`).last(), + ).toBeInViewport(); + }); + } +}); diff --git a/e2e/documents.spec.ts b/e2e/documents.spec.ts index ac82ceebe..4dc51327d 100644 --- a/e2e/documents.spec.ts +++ b/e2e/documents.spec.ts @@ -151,6 +151,57 @@ test.describe("document vault", () => { await ensureVaultFixture(); }); + // ── v1.42 — the archive as a month grid of preview tiles ────────────── + + for (const [width, columns] of [ + [390, 2], + [1440, 4], + ] as const) { + test(`the archive is a month grid of preview tiles, ${columns} across at ${width} px`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: width === 390 ? 844 : 900 }); + await page.goto("/documents"); + const firstRow = page.locator('[data-slot="document-month-row"]').first(); + await expect(firstRow).toBeVisible({ timeout: 15_000 }); + + // Month headings stay the grid's section labels. + await expect( + page.locator('[data-slot="document-timeline"] h2').first(), + ).toBeVisible(); + + // Tiles with the preview on top, and at most `columns` side by side: + // the fullest mounted row holds exactly that many, on one line. + const tiles = page.locator( + '[data-slot="document-month-row"] [data-slot="document-card"]', + ); + await expect(tiles.first()).toHaveAttribute("data-variant", "tile"); + await expect( + tiles.first().locator('[data-slot="document-preview"]'), + ).toBeVisible(); + const rows = await page + .locator('[data-slot="document-month-row"]') + .evaluateAll((els) => + els.map((row) => + Array.from(row.querySelectorAll('[data-slot="document-card"]')).map( + (el) => Math.round(el.getBoundingClientRect().top), + ), + ), + ); + const fullest = rows.reduce((a, b) => (b.length > a.length ? b : a), []); + expect(fullest.length).toBe(columns); + expect(new Set(fullest).size).toBe(1); + + // Nothing on the page scrolls sideways. + const overflow = await page.evaluate( + () => + document.documentElement.scrollWidth - + document.documentElement.clientWidth, + ); + expect(overflow).toBeLessThanOrEqual(0); + }); + } + // ── §1A — the 30-second doctor flow, three independent routes ───────── test("doctor flow A: the type filter reaches the MRT report", async ({ diff --git a/messages/de.json b/messages/de.json index 693b1062f..43b8c151e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7854,6 +7854,34 @@ "backupsStillNeed": "Alle Zeilen sind rotiert, aber Sicherungen brauchen noch {keys}", "backupsUnrecordedBadge": "Alle Zeilen sind rotiert, aber ältere Sicherungen können noch einen ausgemusterten Schlüssel brauchen", "backupsDetail": "Die Rotation verschlüsselt jede Zeile neu, aber eine Sicherung behält den Schlüssel, unter dem ihr Inhalt geschrieben wurde, und ihre Wiederherstellung braucht ihn. Behalte einen ausgemusterten Schlüssel in ENCRYPTION_KEYS, bis er unten nicht mehr aufgeführt ist." + }, + "maintenance": { + "title": "Pflege der Messwert-Tabelle", + "description": "Gibt den Platz frei, den alte Sync-Reste belegt haben, sobald die Bereinigung gelaufen ist.", + "body": "Sie räumt die Messwert-Tabelle auf und baut danach ihre Indizes neu auf, einen nach dem anderen. Lesen und Speichern funktionieren währenddessen weiter. Wähle eine ruhige Stunde und halte freien Speicher für eine weitere Kopie des größten Index bereit.", + "tableSize": "Tabelle mit Indizes", + "indexSize": "Indizes", + "loading": "Stand der Pflege wird geprüft…", + "loadError": "Der Stand der Pflege konnte nicht gelesen werden.", + "unavailable": "Es ist kein Hintergrunddienst verbunden, deshalb kann die Pflege hier nicht laufen.", + "purgePending": "Die Bereinigung alter Sync-Reste läuft noch. Die Pflege kann starten, sobald sie fertig ist.", + "state": { + "idle": "Noch nicht gelaufen.", + "queued": "Wartet auf den Hintergrunddienst.", + "running": "Läuft seit {since}. Auf einer großen Instanz kann das eine Weile dauern.", + "completed": "Fertig am {at}.", + "refusedPurge": "Nicht gestartet, weil die Bereinigung noch lief. Starte sie erneut, sobald die Bereinigung fertig ist.", + "alreadyRunning": "Nicht gestartet, weil bereits ein anderer Lauf arbeitete.", + "failed": "Angehalten am {at}. Das App-Protokoll nennt den Schritt, das Runbook erklärt, was zu prüfen ist." + }, + "start": "Pflege starten", + "confirmTitle": "Pflege jetzt starten?", + "confirmBody": "Sie läuft im Hintergrund, Schritt für Schritt, und kann eine Weile dauern. Wenn du heute noch keine Sicherung erstellt hast, erstelle zuerst eine.", + "confirm": "Starten", + "cancel": "Abbrechen", + "started": "Pflege gestartet.", + "alreadyQueued": "Ein Pflegelauf ist bereits eingeplant oder läuft.", + "startFailed": "Die Pflege konnte nicht gestartet werden." } }, "carrier": "Netzbetreiber", diff --git a/messages/en.json b/messages/en.json index a13020340..3e66a3180 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7854,6 +7854,34 @@ "backupsStillNeed": "Every row is rotated, but backups still need {keys}", "backupsUnrecordedBadge": "Every row is rotated, but older backups may still need a retired key", "backupsDetail": "Rotation re-encrypts every row, but a backup keeps the key its content was written under, and restoring it needs that key. Keep a retired key in ENCRYPTION_KEYS until nothing below lists it." + }, + "maintenance": { + "title": "Measurement table maintenance", + "description": "Gives back the space old sync leftovers held, once the cleanup has run.", + "body": "It vacuums the measurements table and then rebuilds its indexes, one at a time. Reading and saving keep working while it runs. Pick a quiet hour, and have free disk for one more copy of the largest index.", + "tableSize": "Table with indexes", + "indexSize": "Indexes", + "loading": "Checking where the maintenance stands…", + "loadError": "Could not read the maintenance status.", + "unavailable": "No background worker is connected, so the maintenance cannot run here.", + "purgePending": "The cleanup of old sync leftovers is still running. The maintenance can start once it has finished.", + "state": { + "idle": "Not run yet.", + "queued": "Waiting for the background worker.", + "running": "Running since {since}. This can take a while on a large instance.", + "completed": "Finished on {at}.", + "refusedPurge": "Did not start, because the cleanup was still running. Start it again once the cleanup has finished.", + "alreadyRunning": "Did not start, because another run was already working.", + "failed": "Stopped on {at}. The app log names the step, and the runbook explains what to check." + }, + "start": "Start maintenance", + "confirmTitle": "Start the maintenance now?", + "confirmBody": "It runs in the background, one step at a time, and can take a while. If you have not taken a backup today, take one first.", + "confirm": "Start", + "cancel": "Cancel", + "started": "Maintenance started.", + "alreadyQueued": "A maintenance run is already queued or running.", + "startFailed": "Could not start the maintenance." } }, "carrier": "Carrier", diff --git a/messages/es.json b/messages/es.json index 99b380ed8..3d93b7dd2 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7854,6 +7854,34 @@ "backupsStillNeed": "Todas las filas están rotadas, pero las copias aún necesitan {keys}", "backupsUnrecordedBadge": "Todas las filas están rotadas, pero copias antiguas pueden necesitar aún una clave retirada", "backupsDetail": "La rotación vuelve a cifrar cada fila, pero una copia conserva la clave con la que se escribió su contenido, y restaurarla necesita esa clave. Mantén una clave retirada en ENCRYPTION_KEYS hasta que no aparezca abajo." + }, + "maintenance": { + "title": "Mantenimiento de la tabla de mediciones", + "description": "Libera el espacio que ocupaban los restos antiguos de sincronización, cuando la limpieza ya ha terminado.", + "body": "Ejecuta un vacuum de la tabla de mediciones y después reconstruye sus índices, uno a uno. Leer y guardar siguen funcionando mientras se ejecuta. Elige una hora tranquila y deja espacio libre en disco para una copia más del índice más grande.", + "tableSize": "Tabla con índices", + "indexSize": "Índices", + "loading": "Comprobando el estado del mantenimiento…", + "loadError": "No se pudo leer el estado del mantenimiento.", + "unavailable": "No hay ningún proceso en segundo plano conectado, así que el mantenimiento no puede ejecutarse aquí.", + "purgePending": "La limpieza de restos antiguos de sincronización sigue en marcha. El mantenimiento podrá empezar cuando termine.", + "state": { + "idle": "Aún no se ha ejecutado.", + "queued": "Esperando al proceso en segundo plano.", + "running": "En marcha desde las {since}. En una instancia grande puede tardar un rato.", + "completed": "Terminado el {at}.", + "refusedPurge": "No empezó porque la limpieza seguía en marcha. Vuelve a iniciarlo cuando la limpieza haya terminado.", + "alreadyRunning": "No empezó porque ya había otra ejecución trabajando.", + "failed": "Detenido el {at}. El registro de la aplicación indica el paso y el runbook explica qué revisar." + }, + "start": "Iniciar mantenimiento", + "confirmTitle": "¿Iniciar el mantenimiento ahora?", + "confirmBody": "Se ejecuta en segundo plano, paso a paso, y puede tardar un rato. Si hoy aún no has hecho una copia de seguridad, hazla antes.", + "confirm": "Iniciar", + "cancel": "Cancelar", + "started": "Mantenimiento iniciado.", + "alreadyQueued": "Ya hay un mantenimiento en cola o en marcha.", + "startFailed": "No se pudo iniciar el mantenimiento." } }, "carrier": "Operador", diff --git a/messages/fr.json b/messages/fr.json index 21d5bc238..a7cb12f77 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7854,6 +7854,34 @@ "backupsStillNeed": "Toutes les lignes sont renouvelées, mais des sauvegardes ont encore besoin de {keys}", "backupsUnrecordedBadge": "Toutes les lignes sont renouvelées, mais d'anciennes sauvegardes peuvent encore avoir besoin d'une clé retirée", "backupsDetail": "La rotation rechiffre chaque ligne, mais une sauvegarde garde la clé sous laquelle son contenu a été écrit, et sa restauration en a besoin. Gardez une clé retirée dans ENCRYPTION_KEYS tant qu'elle figure ci-dessous." + }, + "maintenance": { + "title": "Maintenance de la table des mesures", + "description": "Libère la place qu'occupaient les anciens restes de synchronisation, une fois le nettoyage terminé.", + "body": "Elle exécute un vacuum de la table des mesures puis reconstruit ses index, un par un. La lecture et l'enregistrement continuent de fonctionner pendant ce temps. Choisissez une heure calme et prévoyez de l'espace disque libre pour une copie supplémentaire du plus grand index.", + "tableSize": "Table avec index", + "indexSize": "Index", + "loading": "Vérification de l'état de la maintenance…", + "loadError": "Impossible de lire l'état de la maintenance.", + "unavailable": "Aucun processus d'arrière-plan n'est connecté, la maintenance ne peut donc pas s'exécuter ici.", + "purgePending": "Le nettoyage des anciens restes de synchronisation est toujours en cours. La maintenance pourra démarrer une fois qu'il sera terminé.", + "state": { + "idle": "Pas encore exécutée.", + "queued": "En attente du processus d'arrière-plan.", + "running": "En cours depuis {since}. Sur une grande instance, cela peut prendre un moment.", + "completed": "Terminée le {at}.", + "refusedPurge": "Pas démarrée, car le nettoyage était encore en cours. Relancez-la une fois le nettoyage terminé.", + "alreadyRunning": "Pas démarrée, car une autre exécution était déjà en cours.", + "failed": "Arrêtée le {at}. Le journal de l'application indique l'étape et le runbook explique quoi vérifier." + }, + "start": "Lancer la maintenance", + "confirmTitle": "Lancer la maintenance maintenant ?", + "confirmBody": "Elle s'exécute en arrière-plan, étape par étape, et peut prendre un moment. Si vous n'avez pas encore fait de sauvegarde aujourd'hui, faites-en une d'abord.", + "confirm": "Lancer", + "cancel": "Annuler", + "started": "Maintenance lancée.", + "alreadyQueued": "Une maintenance est déjà en file d'attente ou en cours.", + "startFailed": "Impossible de lancer la maintenance." } }, "carrier": "Opérateur", diff --git a/messages/it.json b/messages/it.json index 9784f5ae8..2f53844c2 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7854,6 +7854,34 @@ "backupsStillNeed": "Tutte le righe sono ruotate, ma i backup hanno ancora bisogno di {keys}", "backupsUnrecordedBadge": "Tutte le righe sono ruotate, ma backup più vecchi potrebbero ancora richiedere una chiave ritirata", "backupsDetail": "La rotazione cifra di nuovo ogni riga, ma un backup conserva la chiave con cui è stato scritto il suo contenuto, e ripristinarlo richiede quella chiave. Tieni una chiave ritirata in ENCRYPTION_KEYS finché compare qui sotto." + }, + "maintenance": { + "title": "Manutenzione della tabella delle misurazioni", + "description": "Libera lo spazio occupato dai vecchi residui di sincronizzazione, una volta terminata la pulizia.", + "body": "Esegue un vacuum della tabella delle misurazioni e poi ricostruisce i suoi indici, uno alla volta. Lettura e salvataggio continuano a funzionare durante l'esecuzione. Scegli un'ora tranquilla e lascia spazio libero su disco per un'altra copia dell'indice più grande.", + "tableSize": "Tabella con indici", + "indexSize": "Indici", + "loading": "Verifica dello stato della manutenzione…", + "loadError": "Impossibile leggere lo stato della manutenzione.", + "unavailable": "Nessun processo in background è collegato, quindi la manutenzione non può essere eseguita qui.", + "purgePending": "La pulizia dei vecchi residui di sincronizzazione è ancora in corso. La manutenzione potrà partire quando sarà terminata.", + "state": { + "idle": "Non ancora eseguita.", + "queued": "In attesa del processo in background.", + "running": "In corso dalle {since}. Su un'istanza grande può richiedere un po' di tempo.", + "completed": "Terminata il {at}.", + "refusedPurge": "Non avviata, perché la pulizia era ancora in corso. Riavviala quando la pulizia sarà terminata.", + "alreadyRunning": "Non avviata, perché un'altra esecuzione era già in corso.", + "failed": "Interrotta il {at}. Il registro dell'app indica il passaggio e il runbook spiega cosa controllare." + }, + "start": "Avvia manutenzione", + "confirmTitle": "Avviare la manutenzione adesso?", + "confirmBody": "Viene eseguita in background, un passaggio alla volta, e può richiedere un po' di tempo. Se oggi non hai ancora creato un backup, creane uno prima.", + "confirm": "Avvia", + "cancel": "Annulla", + "started": "Manutenzione avviata.", + "alreadyQueued": "Una manutenzione è già in coda o in corso.", + "startFailed": "Impossibile avviare la manutenzione." } }, "carrier": "Operatore", diff --git a/messages/ko.json b/messages/ko.json index aa0d855ec..bc2f083fc 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7830,6 +7830,34 @@ "backupsStillNeed": "모든 행이 회전되었지만 백업에 아직 {keys}가 필요합니다", "backupsUnrecordedBadge": "모든 행이 회전되었지만 이전 백업에는 아직 폐기한 키가 필요할 수 있습니다", "backupsDetail": "회전은 모든 행을 다시 암호화하지만, 백업은 내용이 기록된 키를 그대로 유지하며 복원에는 그 키가 필요합니다. 아래에 더 이상 표시되지 않을 때까지 폐기한 키를 ENCRYPTION_KEYS에 유지하세요." + }, + "maintenance": { + "title": "측정값 테이블 유지 관리", + "description": "정리가 끝난 뒤, 오래된 동기화 잔여 데이터가 차지하던 공간을 되돌려 줍니다.", + "body": "측정값 테이블에 vacuum을 실행한 다음 인덱스를 하나씩 다시 만듭니다. 실행 중에도 읽기와 저장은 계속 작동합니다. 한가한 시간을 고르고, 가장 큰 인덱스를 한 번 더 복사할 만큼의 디스크 여유 공간을 확보하세요.", + "tableSize": "인덱스 포함 테이블", + "indexSize": "인덱스", + "loading": "유지 관리 상태를 확인하는 중…", + "loadError": "유지 관리 상태를 읽을 수 없습니다.", + "unavailable": "연결된 백그라운드 작업자가 없어 여기에서는 유지 관리를 실행할 수 없습니다.", + "purgePending": "오래된 동기화 잔여 데이터 정리가 아직 진행 중입니다. 정리가 끝나면 유지 관리를 시작할 수 있습니다.", + "state": { + "idle": "아직 실행되지 않았습니다.", + "queued": "백그라운드 작업자를 기다리는 중입니다.", + "running": "{since}부터 실행 중입니다. 큰 인스턴스에서는 시간이 걸릴 수 있습니다.", + "completed": "{at}에 완료되었습니다.", + "refusedPurge": "정리가 아직 진행 중이어서 시작되지 않았습니다. 정리가 끝난 뒤 다시 시작하세요.", + "alreadyRunning": "다른 실행이 이미 작업 중이어서 시작되지 않았습니다.", + "failed": "{at}에 중단되었습니다. 앱 로그에 단계가 나와 있고, 런북에서 확인할 사항을 설명합니다." + }, + "start": "유지 관리 시작", + "confirmTitle": "지금 유지 관리를 시작할까요?", + "confirmBody": "백그라운드에서 한 단계씩 실행되며 시간이 걸릴 수 있습니다. 오늘 아직 백업하지 않았다면 먼저 백업하세요.", + "confirm": "시작", + "cancel": "취소", + "started": "유지 관리를 시작했습니다.", + "alreadyQueued": "유지 관리가 이미 대기 중이거나 실행 중입니다.", + "startFailed": "유지 관리를 시작할 수 없습니다." } }, "carrier": "통신사", diff --git a/messages/pl.json b/messages/pl.json index 80234db49..c6bf083d6 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7854,6 +7854,34 @@ "backupsStillNeed": "Wszystkie wiersze są zrotowane, ale kopie nadal potrzebują {keys}", "backupsUnrecordedBadge": "Wszystkie wiersze są zrotowane, ale starsze kopie mogą nadal potrzebować wycofanego klucza", "backupsDetail": "Rotacja ponownie szyfruje każdy wiersz, ale kopia zachowuje klucz, którym zapisano jej zawartość, a jej przywrócenie wymaga tego klucza. Trzymaj wycofany klucz w ENCRYPTION_KEYS, dopóki widnieje poniżej." + }, + "maintenance": { + "title": "Konserwacja tabeli pomiarów", + "description": "Zwalnia miejsce zajmowane przez stare pozostałości synchronizacji, gdy czyszczenie już się zakończy.", + "body": "Wykonuje vacuum tabeli pomiarów, a potem odbudowuje jej indeksy, jeden po drugim. Odczyt i zapis działają w tym czasie dalej. Wybierz spokojną godzinę i zapewnij wolne miejsce na dysku na jeszcze jedną kopię największego indeksu.", + "tableSize": "Tabela z indeksami", + "indexSize": "Indeksy", + "loading": "Sprawdzanie stanu konserwacji…", + "loadError": "Nie udało się odczytać stanu konserwacji.", + "unavailable": "Nie jest podłączony żaden proces w tle, więc konserwacja nie może tu działać.", + "purgePending": "Czyszczenie starych pozostałości synchronizacji wciąż trwa. Konserwacja może się rozpocząć, gdy się zakończy.", + "state": { + "idle": "Jeszcze nie uruchomiona.", + "queued": "Czeka na proces w tle.", + "running": "Trwa od {since}. Na dużej instancji może to chwilę potrwać.", + "completed": "Zakończona {at}.", + "refusedPurge": "Nie rozpoczęła się, bo czyszczenie wciąż trwało. Uruchom ją ponownie, gdy czyszczenie się zakończy.", + "alreadyRunning": "Nie rozpoczęła się, bo inny przebieg już działał.", + "failed": "Zatrzymana {at}. Dziennik aplikacji podaje krok, a runbook wyjaśnia, co sprawdzić." + }, + "start": "Uruchom konserwację", + "confirmTitle": "Uruchomić konserwację teraz?", + "confirmBody": "Działa w tle, krok po kroku, i może chwilę potrwać. Jeśli dziś nie zrobiono jeszcze kopii zapasowej, najpierw ją zrób.", + "confirm": "Uruchom", + "cancel": "Anuluj", + "started": "Konserwacja uruchomiona.", + "alreadyQueued": "Konserwacja jest już w kolejce lub trwa.", + "startFailed": "Nie udało się uruchomić konserwacji." } }, "carrier": "Operator", diff --git a/src/__tests__/error-level-discipline-guard.test.ts b/src/__tests__/error-level-discipline-guard.test.ts index 9dd6e6552..bd1b96be9 100644 --- a/src/__tests__/error-level-discipline-guard.test.ts +++ b/src/__tests__/error-level-discipline-guard.test.ts @@ -190,6 +190,17 @@ const ALLOWED: Readonly> = { count: 1, reason: "See readActiveJobsStartedBefore: null means no queue schema.", }, + "lib/jobs/measurement-maintenance-status.ts::readMeasurementMaintenanceStatus": + { + count: 1, + reason: + "No pgboss schema on a web-only deployment; the admin card reads `available: false` and says so.", + }, + "lib/jobs/measurement-maintenance-status.ts::readSizes": { + count: 1, + reason: + "A size probe for an admin card: null shows no figure, and the status read must not fail over it.", + }, "lib/jobs/job-observer.ts::emitJobLine": { count: 1, reason: diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 64fb05022..13e60f025 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -97,6 +97,11 @@ const PINNED_AFFORDANCES: Record< "toast.success": 1, }, "src/components/admin/glitchtip-section.tsx": { "toast.success": 1 }, + // Fires only when the route answered `enqueued: true`; a run already queued + // reads as an info toast instead, and the outcome itself is the status line. + "src/components/admin/measurement-maintenance-card.tsx": { + "toast.success": 1, + }, "src/components/admin/invite-tokens-section.tsx": { "text-success": 2, "toast.success": 3, diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx index cfb9fdc8e..89cb04f39 100644 --- a/src/app/admin/[section]/renderer.tsx +++ b/src/app/admin/[section]/renderer.tsx @@ -5,7 +5,7 @@ * a given slug. Lives next to the route page so the shell can stay a * server component while the auth-gated body remains a client component. * - * This is the equivalent of `SECTION_COMPONENTS` in + * This is the equivalent of `SettingsSectionLoader` for * `src/app/settings/[section]/page.tsx`, lifted to a separate file so * the route page can be a server component (Settings can keep * `"use client"`-leaning components in its page because none of them @@ -36,6 +36,7 @@ import { ModuleAvailabilitySection } from "@/components/admin/module-availabilit import { RemindersSection } from "@/components/admin/reminders-section"; import { ServicesSection } from "@/components/admin/services-section"; import { SystemStatusSection } from "@/components/admin/system-status-section"; +import { MeasurementMaintenanceCard } from "@/components/admin/measurement-maintenance-card"; import { InviteTokensSection } from "@/components/admin/invite-tokens-section"; import { UserManagementSection } from "@/components/admin/user-management-section"; import type { AdminSectionSlug } from "@/components/admin/section-slugs"; @@ -60,6 +61,8 @@ export function AdminSectionRenderer({ return ( + {/* v1.42 — the table maintenance after the tombstone purge. */} + ); case "general": diff --git a/src/app/api/admin/maintenance/measurements/__tests__/status.test.ts b/src/app/api/admin/maintenance/measurements/__tests__/status.test.ts new file mode 100644 index 000000000..b4cb3012e --- /dev/null +++ b/src/app/api/admin/maintenance/measurements/__tests__/status.test.ts @@ -0,0 +1,143 @@ +/** + * `GET /api/admin/maintenance/measurements` — the status the admin card + * starts and follows the run with. Admin-only, and a web-only deployment + * (no pg-boss schema) answers `available: false` instead of failing. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ prisma: { $queryRaw: vi.fn() } })); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { runStateOf } from "@/lib/jobs/measurement-maintenance-status"; + +const ADMIN = { + session: { id: "s-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "admin-1", username: "admin", role: "ADMIN" as const }, +}; +const USER = { + session: { id: "s-2", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "bob", role: "USER" as const }, +}; + +const get = () => + (GET as unknown as (r: NextRequest) => Promise)( + new NextRequest("http://localhost/api/admin/maintenance/measurements"), + ); + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("GET /api/admin/maintenance/measurements", () => { + it("is refused to anyone but an admin", async () => { + vi.mocked(getSession).mockResolvedValue(USER as never); + expect((await get()).status).toBe(403); + expect(prisma.$queryRaw).not.toHaveBeenCalled(); + }); + + it("reports a running pass, the purge and the table size", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN as never); + vi.mocked(prisma.$queryRaw) + // sizes + .mockResolvedValueOnce([ + { table_bytes: BigInt(5_000_000_000), index_bytes: BigInt(2e9) }, + ] as never) + // newest run + .mockResolvedValueOnce([ + { + state: "active", + created_on: new Date("2026-10-08T08:00:00Z"), + started_on: new Date("2026-10-08T08:00:05Z"), + completed_on: null, + outcome: null, + }, + ] as never) + // purge queue + .mockResolvedValueOnce([{ n: 0 }] as never); + + const body = await (await get()).json(); + expect(body.data).toEqual({ + available: true, + purgePending: false, + run: { + state: "running", + requestedAt: "2026-10-08T08:00:00.000Z", + startedAt: "2026-10-08T08:00:05.000Z", + finishedAt: null, + outcome: null, + }, + sizes: { tableBytes: 5_000_000_000, indexBytes: 2_000_000_000 }, + }); + }); + + it("says when a finished run refused because the purge was still working", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN as never); + vi.mocked(prisma.$queryRaw) + .mockResolvedValueOnce([ + { table_bytes: BigInt(1), index_bytes: BigInt(1) }, + ] as never) + .mockResolvedValueOnce([ + { + state: "completed", + created_on: new Date("2026-10-08T08:00:00Z"), + started_on: new Date("2026-10-08T08:00:01Z"), + completed_on: new Date("2026-10-08T08:00:02Z"), + outcome: "refused_purge_running", + }, + ] as never) + .mockResolvedValueOnce([{ n: 1 }] as never); + + const body = await (await get()).json(); + expect(body.data.purgePending).toBe(true); + expect(body.data.run.outcome).toBe("refused_purge_running"); + }); + + it("answers available: false where there is no queue to ask", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN as never); + vi.mocked(prisma.$queryRaw) + .mockResolvedValueOnce([ + { table_bytes: BigInt(10), index_bytes: BigInt(4) }, + ] as never) + .mockRejectedValue(new Error('relation "pgboss.job" does not exist')); + + const res = await get(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toEqual({ + available: false, + purgePending: false, + run: null, + sizes: { tableBytes: 10, indexBytes: 4 }, + }); + }); +}); + +describe("runStateOf", () => { + it("maps every pg-boss state onto the card's four", () => { + expect(runStateOf("created")).toBe("queued"); + expect(runStateOf("retry")).toBe("queued"); + expect(runStateOf("active")).toBe("running"); + expect(runStateOf("completed")).toBe("completed"); + expect(runStateOf("failed")).toBe("failed"); + expect(runStateOf("cancelled")).toBe("failed"); + }); +}); diff --git a/src/app/api/admin/maintenance/measurements/route.ts b/src/app/api/admin/maintenance/measurements/route.ts index 618f02fbc..37fa23109 100644 --- a/src/app/api/admin/maintenance/measurements/route.ts +++ b/src/app/api/admin/maintenance/measurements/route.ts @@ -8,6 +8,9 @@ * start while the compaction-tombstone purge is still working. At most one * run is queued or running; a second press answers `enqueued: false`. * Runbook: `docs/ops/measurement-maintenance.md`. + * + * `GET` reads where the newest run stands, for the admin card that starts it + * and follows it. */ import { NextRequest } from "next/server"; import { z } from "zod/v4"; @@ -26,6 +29,7 @@ import { MEASUREMENT_MAINTENANCE_SEND_OPTIONS, type MeasurementMaintenancePayload, } from "@/lib/jobs/measurement-maintenance"; +import { readMeasurementMaintenanceStatus } from "@/lib/jobs/measurement-maintenance-status"; import { annotate } from "@/lib/logging/context"; /** Two booleans; anything longer is not a request for this route. */ @@ -90,3 +94,17 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiSuccess({ enqueued }, 202); }); + +export const GET = apiHandler(async () => { + await requireAdmin(); + const status = await readMeasurementMaintenanceStatus(); + annotate({ + action: { name: "admin.maintenance.measurements.status" }, + meta: { + available: status.available, + run_state: status.run?.state ?? null, + purge_pending: status.purgePending, + }, + }); + return apiSuccess(status); +}); diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index f5c4a559a..e4834c44b 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -1,33 +1,14 @@ import { notFound } from "next/navigation"; -import type { JSX } from "react"; -import { AccessSection } from "@/components/settings/access-section"; -import { AccountSection } from "@/components/settings/account-section"; -import { AnamnesisSection } from "@/components/settings/anamnesis-section"; -import { SecuritySection } from "@/components/settings/security-section"; -import { AboutSection } from "@/components/settings/about-section"; -import { AdvancedSection } from "@/components/settings/advanced-section"; -import { AiSection } from "@/components/settings/ai-section"; -import { ApiSection } from "@/components/settings/api-section"; -import { CoachSection } from "@/components/settings/coach-section"; -import { EnvironmentSection } from "@/components/settings/environment-section"; -import { ExportSection } from "@/components/settings/export-section"; -import { GesundheitsakteSection } from "@/components/settings/gesundheitsakte-section"; -import { IntegrationsSection } from "@/components/settings/integrations-section"; -import { LayoutSection } from "@/components/settings/layout-section"; -import { McpSection } from "@/components/settings/mcp-section"; -import { ModulesSection } from "@/components/settings/modules-section"; -import { NotificationsSection } from "@/components/settings/notifications-section"; -import { PrivacySection } from "@/components/settings/privacy-section"; -import { ScoreSection } from "@/components/settings/score-section"; -import { SourcesSection } from "@/components/settings/sources-section"; -import { ThresholdsSection } from "@/components/settings/thresholds-section"; import { SETTINGS_SECTION_SLUGS, isSettingsSectionSlug, - type SettingsSectionSlug, } from "@/components/settings/section-slugs"; import { SettingsShell } from "@/components/settings/settings-shell"; +import { + SettingsSectionLoader, + SettingsSectionPreload, +} from "@/components/settings/settings-section-loader"; import { RecordSettingsSectionGate } from "@/components/settings/record-settings-section-gate"; import { getIntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; @@ -47,44 +28,6 @@ export function generateStaticParams() { return SETTINGS_SECTION_SLUGS.map((section) => ({ section })); } -/** - * Integrations is the one section that needs a server-resolved value: the - * OAuth callback URL each provider registers. It is read from the runtime env - * here, per request, and handed to the client section as a prop, because a - * `NEXT_PUBLIC_*` read inside a client module is inlined at build time and the - * published image is built without it. - */ -function IntegrationsSectionWithCallbackUrls() { - return ; -} - -const SECTION_COMPONENTS: Record< - SettingsSectionSlug, - () => JSX.Element | null -> = { - account: AccountSection, - access: AccessSection, - security: SecuritySection, - modules: ModulesSection, - about: AboutSection, - ai: AiSection, - coach: CoachSection, - integrations: IntegrationsSectionWithCallbackUrls, - sources: SourcesSection, - notifications: NotificationsSection, - layout: LayoutSection, - environment: EnvironmentSection, - anamnesis: AnamnesisSection, - score: ScoreSection, - thresholds: ThresholdsSection, - api: ApiSection, - mcp: McpSection, - gesundheitsakte: GesundheitsakteSection, - export: ExportSection, - advanced: AdvancedSection, - privacy: PrivacySection, -}; - interface PageProps { // Next.js 16 made route `params` an async Promise. We `await` it before use. params: Promise<{ section: string }>; @@ -100,10 +43,13 @@ export default async function SettingsSectionPage({ params }: PageProps) { notFound(); } - // `SECTION_COMPONENTS` is an exhaustive Record over the slug union, so a - // slug without a wired component is a compile error — the old runtime - // `` fallback could never render and was removed. - const SectionComponent = SECTION_COMPONENTS[section]; + // Each section is its own chunk (see `SettingsSectionLoader`), so the route + // ships the one it shows rather than all twenty-one. Integrations is the + // one section that needs a server-resolved value: the OAuth callback URL + // each provider registers, read from the runtime env here, per request, + // because a `NEXT_PUBLIC_*` read inside a client module is inlined at build + // time and the published image is built without it. + // // v1.18.6.1 — the heading + subtitle (and the Layout-hub "← back" link) // live in ``, which places them in their own grid row // spanning only the content column so the left nav's first item lines up @@ -112,12 +58,20 @@ export default async function SettingsSectionPage({ params }: PageProps) { // `settings-section--title` `aria-labelledby` linkage still resolves. return ( +
- +
diff --git a/src/components/admin/measurement-maintenance-card.tsx b/src/components/admin/measurement-maintenance-card.tsx new file mode 100644 index 000000000..91baf7a5a --- /dev/null +++ b/src/components/admin/measurement-maintenance-card.tsx @@ -0,0 +1,247 @@ +"use client"; + +/** + * `` — start the measurement table maintenance + * and follow it, from the admin console instead of a `curl` against the + * route (runbook: `docs/ops/measurement-maintenance.md`). + * + * Calm on purpose. The button asks once, says what the run does and that it + * can take a while, and then the card shows where the run stands: waiting, + * running since when, finished, or stopped. It polls only while a run is + * queued or running. It stays disabled while the compaction-tombstone purge + * is still at work, because a run started then would refuse at once. + */ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Database, Loader2, Wrench } from "lucide-react"; +import { toast } from "sonner"; + +import { StatTile } from "@/components/admin/_stat-tile"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; +import { formatBytes } from "@/lib/format/bytes"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { MeasurementMaintenanceStatus } from "@/lib/jobs/measurement-maintenance-status"; +import { queryKeys } from "@/lib/query-keys"; +import { getApiErrorMessage } from "./_shared"; + +const ENDPOINT = "/api/admin/maintenance/measurements"; + +/** How often the card asks while a run is waiting or working. */ +const POLL_MS = 10_000; + +const GIB = 1024 * 1024 * 1024; + +function sizeLabel( + bytes: number, + fmt: ReturnType, +): string { + // The shared formatter stops at MB; this table is routinely gigabytes. + return bytes >= GIB + ? `${fmt.number(bytes / GIB, 2)} GB` + : formatBytes(bytes, fmt); +} + +export function MeasurementMaintenanceCard() { + const { t } = useTranslations(); + const fmt = useFormatters(); + const queryClient = useQueryClient(); + + const status = useQuery({ + queryKey: queryKeys.adminMeasurementMaintenance(), + queryFn: () => + apiGet(ENDPOINT, { + credentials: "include", + }), + refetchInterval: (query) => { + const state = query.state.data?.run?.state; + return state === "queued" || state === "running" ? POLL_MS : false; + }, + }); + + const start = useMutation({ + mutationFn: async (): Promise<{ enqueued: boolean }> => { + const res = await apiFetchRaw(ENDPOINT, { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ vacuum: true, reindex: true }), + }); + if (!res.ok) throw new Error(await getApiErrorMessage(res)); + return ((await res.json()) as { data: { enqueued: boolean } }).data; + }, + onSuccess: ({ enqueued }) => { + if (enqueued) toast.success(t("admin.section.maintenance.started")); + else toast.info(t("admin.section.maintenance.alreadyQueued")); + void queryClient.invalidateQueries({ + queryKey: queryKeys.adminMeasurementMaintenance(), + }); + }, + onError: (err: Error) => { + toast.error(err.message || t("admin.section.maintenance.startFailed")); + }, + }); + + const header = ( + + ); + + if (status.isError) { + return ( + void status.refetch()} + /> + ); + } + + const s = status.data; + const run = s?.run ?? null; + const active = run?.state === "queued" || run?.state === "running"; + const blocked = !s || !s.available || s.purgePending || active; + + return ( + + {header} +

{t("admin.section.maintenance.body")}

+ + {s?.sizes ? ( +
+ + +
+ ) : null} + +
+ {status.isLoading ? ( + + + {t("admin.section.maintenance.loading")} + + ) : !s?.available ? ( + + {t("admin.section.maintenance.unavailable")} + + ) : run?.state === "queued" ? ( + + + {t("admin.section.maintenance.state.queued")} + + ) : run?.state === "running" ? ( + + + {t("admin.section.maintenance.state.running", { + since: fmt.time(run.startedAt ?? run.requestedAt), + })} + + ) : run?.state === "completed" ? ( + + {run.outcome === "refused_purge_running" + ? t("admin.section.maintenance.state.refusedPurge") + : run.outcome === "already_running" + ? t("admin.section.maintenance.state.alreadyRunning") + : t("admin.section.maintenance.state.completed", { + at: fmt.dateTime(run.finishedAt ?? run.requestedAt), + })} + + ) : run?.state === "failed" ? ( + + {t("admin.section.maintenance.state.failed", { + at: fmt.dateTime(run.finishedAt ?? run.requestedAt), + })} + + ) : ( + + {t("admin.section.maintenance.state.idle")} + + )} +
+ + {s?.available && s.purgePending ? ( +

+ {t("admin.section.maintenance.purgePending")} +

+ ) : null} + + + + + + + + + + {t("admin.section.maintenance.confirmTitle")} + + + {t("admin.section.maintenance.confirmBody")} + + + + + {t("admin.section.maintenance.cancel")} + + start.mutate()}> + {t("admin.section.maintenance.confirm")} + + + + + +
+ ); +} diff --git a/src/components/documents/__tests__/document-card.test.tsx b/src/components/documents/__tests__/document-card.test.tsx index 8de53bd90..986f27594 100644 --- a/src/components/documents/__tests__/document-card.test.tsx +++ b/src/components/documents/__tests__/document-card.test.tsx @@ -336,3 +336,40 @@ describe("", () => { expect(html).toContain("1 GB"); }); }); + +describe(' (the archive grid)', () => { + it("puts a 4:3 preview above the title, falling back to the kind icon", () => { + const html = render( + , + ); + expect(html).toContain('data-variant="tile"'); + const preview = html.indexOf('data-slot="document-preview"'); + expect(preview).toBeGreaterThan(-1); + expect(html).toMatch(/data-slot="document-preview"[^>]*aspect-\[4\/3\]/); + // Preview first, title after it. + expect(html.indexOf("MRT Knie")).toBeGreaterThan(preview); + // No thumbnail rendered yet: the kind icon fills the preview, no . + expect(html).not.toContain(" { + const html = render( + , + ); + expect(html).toContain('data-variant="row"'); + expect(html).not.toContain('data-slot="document-preview"'); + }); +}); diff --git a/src/components/documents/__tests__/document-month-grid.test.tsx b/src/components/documents/__tests__/document-month-grid.test.tsx new file mode 100644 index 000000000..c0b2a8a1b --- /dev/null +++ b/src/components/documents/__tests__/document-month-grid.test.tsx @@ -0,0 +1,52 @@ +/** + * The archive grid's shape: two tiles side by side on a phone, three or four + * on a desktop, measured from the grid's own width (the AuthShell inset is + * already taken off by the time the grid measures itself). + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + columnsForWidth, + DocumentMonthHeading, + estimatedRowHeight, +} from "../document-month-grid"; + +describe("columnsForWidth", () => { + it("lays a 390 px phone out in two columns, the narrowest screens in one", () => { + // 390 viewport minus the shell's 16 px gutters. + expect(columnsForWidth(358)).toBe(2); + expect(columnsForWidth(320 - 32)).toBe(1); + }); + + it("uses three columns on a small desktop and four on a wide one", () => { + expect(columnsForWidth(1024 - 48)).toBe(3); + // 1440 viewport: the shell caps the container at 1280 minus its gutters. + expect(columnsForWidth(1280 - 48)).toBe(4); + }); + + it("never asks for a fifth column", () => { + expect(columnsForWidth(4000)).toBe(4); + }); +}); + +describe("estimatedRowHeight", () => { + it("grows with the tile's width, so the first guess is close to the measured row", () => { + expect(estimatedRowHeight(358, 2)).toBeLessThan( + estimatedRowHeight(1232, 2), + ); + expect(estimatedRowHeight(358, 2)).toBeGreaterThan(200); + }); +}); + +describe("", () => { + it("keeps the month heading as the grid's section label", () => { + const html = renderToStaticMarkup( + + + , + ); + expect(html).toMatch(/^

]*uppercase[^>]*>March 2026<\/h2>$/); + }); +}); diff --git a/src/components/documents/document-card.tsx b/src/components/documents/document-card.tsx index 92f12addf..0b739ff45 100644 --- a/src/components/documents/document-card.tsx +++ b/src/components/documents/document-card.tsx @@ -52,7 +52,14 @@ export function DocumentCard({ tabIndex = 0, onCardFocus, onPrefetch, + variant = "row", }: { + /** + * `row` (default): the compact card with the preview at its leading edge. + * `tile`: the archive grid's tile, the preview filling the top of the card + * so a page is recognisable before its title is read. + */ + variant?: "row" | "tile"; document: InboundDocumentDto; selected: boolean; /** `range` = extend the selection from the last anchor (shift-click). */ @@ -127,9 +134,76 @@ export function DocumentCard({ wasProcessingRef.current = processing; }, [processing]); + const tile = variant === "tile"; + const showThumbnail = !!thumbnailUrl && !thumbnailFailed; + // An object URL over bytes the app transport already fetched, not a + // `/api/…` subresource: the browser issues a subresource itself and so + // cannot carry the record-session assertion, which the fence refuses on any + // session that has been inside a shared record. `next/image` is wrong here + // for the older reason too — it would proxy a PHI blob we deliberately + // never cache. Decorative (alt="") — the title beside it names the + // document. The laziness that `loading="lazy"` used to provide now lives in + // the hook's null path: a card with no thumbnail, or one whose thumbnail + // already failed, issues no request at all. onError falls back to the kind + // icon. + const thumbnail = showThumbnail ? ( + // eslint-disable-next-line @next/next/no-img-element + setThumbFailed(true)} + className={cn("size-full object-cover", tile && "object-top")} + /> + ) : null; + + const selection = onToggleSelected ? ( + { + e.preventDefault(); + onToggleSelected(document.id, e.shiftKey); + }} + aria-label={t("documents.card.selectLabel", { title })} + className={cn( + "relative z-10 shrink-0 transition-opacity", + "opacity-0 group-focus-within:opacity-100 group-hover:opacity-100", + selected && "opacity-100", + // On a tile the box floats over the preview, which can be any + // colour; the card background behind it keeps it readable. + tile && "bg-background absolute top-4 right-4", + )} + /> + ) : null; + + const titleBlock = ( +
+

{title}

+

+ {date} · {size} +

+ {showFilename ? ( + // Filename on its OWN muted line so it never clips the date/size + // meta; the full name stays reachable on hover via `title`. +

+ {document.filename} +

+ ) : null} +
+ ); + return ( - -
- {thumbnailUrl && !thumbnailFailed ? ( - // Leading-edge preview tile. Decorative (alt="") — the title - // beside it names the document. The laziness that `loading="lazy"` - // used to provide now lives in the hook's null path: a card with no - // thumbnail, or one whose thumbnail already failed, issues no - // request at all. onError falls back to the kind icon. + + {tile ? ( + <> - {/* An object URL over bytes the app transport already fetched, - not a `/api/…` subresource: the browser issues a subresource - itself and so cannot carry the record-session assertion, which - the fence refuses on any session that has been inside a shared - record. `next/image` is wrong here for the older reason too — - it would proxy a PHI blob we deliberately never cache. */} - {/* eslint-disable-next-line @next/next/no-img-element */} - setThumbFailed(true)} - className="size-full object-cover" - /> - - ) : ( - - )} -
-

{title}

-

- {date} · {size} -

- {showFilename ? ( - // Filename on its OWN muted line so it never clips the date/size - // meta; the full name stays reachable on hover via `title`. -

- {document.filename} -

- ) : null} -
- {onToggleSelected && ( - { - e.preventDefault(); - onToggleSelected(document.id, e.shiftKey); - }} - aria-label={t("documents.card.selectLabel", { title })} - className={cn( - "relative z-10 shrink-0 transition-opacity", - "opacity-0 group-focus-within:opacity-100 group-hover:opacity-100", - selected && "opacity-100", + {thumbnail ?? ( + )} - /> - )} -
+ + {titleBlock} + {selection} + + ) : ( +
+ {thumbnail ? ( + // Leading-edge preview tile. + + {thumbnail} + + ) : ( + + )} + {titleBlock} + {selection} +
+ )} {document.conditionLinks.length > 0 || document.servingClass === "attachment" || processing || diff --git a/src/components/documents/document-month-grid.tsx b/src/components/documents/document-month-grid.tsx new file mode 100644 index 000000000..bcf8f22a9 --- /dev/null +++ b/src/components/documents/document-month-grid.tsx @@ -0,0 +1,92 @@ +"use client"; + +/** + * The archive's month grid: a month heading, then that month's documents as + * preview tiles, so a page is recognisable by its look before its title is + * read. The timeline owns windowing and keyboard focus; this module owns how + * one windowed item is laid out and how many tiles share a row. + * + * Two tiles side by side on a phone (one on the narrowest screens), three or + * four on a desktop. The count comes from the grid's measured width, not from + * viewport breakpoints, so the virtualizer's row model and the painted grid + * can never disagree. + */ +import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; +import { DocumentCard } from "./document-card"; + +/** Measured grid width → tiles per row. */ +export function columnsForWidth(width: number): number { + if (width >= 1100) return 4; + if (width >= 760) return 3; + if (width >= 330) return 2; + return 1; +} + +/** Gap between tiles and between rows, in px (`gap-4` / `pb-4`). */ +const GRID_GAP_PX = 16; + +/** + * First guess at a row's height before the virtualizer measures it: a 4:3 + * preview across the tile's width plus the title and meta lines under it. + */ +export function estimatedRowHeight(width: number, columns: number): number { + const cols = Math.max(1, columns); + const tileWidth = Math.max(0, (width - GRID_GAP_PX * (cols - 1)) / cols); + return Math.round(tileWidth * 0.75) + 96 + GRID_GAP_PX; +} + +export function DocumentMonthHeading({ label }: { label: string }) { + return ( +

+ {label} +

+ ); +} + +export function DocumentMonthRow({ + documents, + columns, + selectedIds, + onToggleSelected, + onOpen, + onDelete, + highlightId, + rovingId, + onCardFocus, + onPrefetch, +}: { + documents: InboundDocumentDto[]; + columns: number; + selectedIds: ReadonlySet; + onToggleSelected?: (id: string, range?: boolean) => void; + onOpen: (id: string) => void; + onDelete?: (id: string) => void; + highlightId: string | null; + rovingId: string | null; + onCardFocus: (id: string) => void; + onPrefetch?: (id: string) => void; +}) { + return ( +
+ {documents.map((doc) => ( + + ))} +
+ ); +} diff --git a/src/components/documents/document-timeline.tsx b/src/components/documents/document-timeline.tsx index 25faca739..bbc151d45 100644 --- a/src/components/documents/document-timeline.tsx +++ b/src/components/documents/document-timeline.tsx @@ -1,7 +1,8 @@ "use client"; /** - * The vault timeline: a virtualized, month-sectioned card grid windowed + * The vault timeline: a virtualized, month-sectioned grid of preview tiles + * (`document-month-grid.tsx`) windowed * with `@tanstack/react-virtual` over the shell's scroll container * (`#main-content` — single scroll owner per the design standards; the * timeline never brings its own scrollport). The flat item list (month @@ -9,8 +10,9 @@ * mounted DOM stays bounded (< ~400 nodes) regardless of corpus size. * * Columns are measured, not breakpoint-classed: a ResizeObserver on the - * grid container drives the per-row chunking (4 / 3 / 2 / 1), which keeps - * the virtualizer's row model and the painted grid in lockstep. + * grid container drives the per-row chunking (4 / 3 on a desktop, 2 on a + * phone, 1 on the narrowest screens), which keeps the virtualizer's row model + * and the painted grid in lockstep. * * In-flight / failed uploads render as a small non-virtualized grid above * the timeline — they are few (client concurrency 3) and must appear @@ -28,20 +30,18 @@ import { useVirtualizer } from "@tanstack/react-virtual"; import { useTranslations } from "@/lib/i18n/context"; import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; -import { DocumentCard, UploadStateCard } from "./document-card"; +import { UploadStateCard } from "./document-card"; +import { + columnsForWidth, + DocumentMonthHeading, + DocumentMonthRow, + estimatedRowHeight, +} from "./document-month-grid"; import type { UploadQueueItem } from "./use-document-upload"; import { buildTimelineItems, formatMonthLabel } from "./vault-utils"; const SCROLL_CONTAINER_ID = "main-content"; -/** Measured-width → column count (desktop 4/3, tablet 2, phone 1). */ -function columnsForWidth(width: number): number { - if (width >= 1200) return 4; - if (width >= 900) return 3; - if (width >= 600) return 2; - return 1; -} - export function DocumentTimeline({ documents, uploadItems, @@ -77,6 +77,7 @@ export function DocumentTimeline({ const { t, locale } = useTranslations(); const listRef = useRef(null); const [columns, setColumns] = useState(1); + const [gridWidth, setGridWidth] = useState(0); const [scrollMargin, setScrollMargin] = useState(0); // Roving tabindex over the card grid: exactly one card is tabbable; the @@ -88,7 +89,10 @@ export function DocumentTimeline({ useEffect(() => { const el = listRef.current; if (!el) return; - const update = () => setColumns(columnsForWidth(el.clientWidth)); + const update = () => { + setColumns(columnsForWidth(el.clientWidth)); + setGridWidth(el.clientWidth); + }; update(); const ro = new ResizeObserver(update); ro.observe(el); @@ -127,7 +131,10 @@ export function DocumentTimeline({ const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => document.getElementById(SCROLL_CONTAINER_ID), - estimateSize: (index) => (items[index].type === "month" ? 40 : 140), + estimateSize: (index) => + items[index].type === "month" + ? 40 + : estimatedRowHeight(gridWidth, columns), getItemKey: (index) => items[index].key, overscan: 6, scrollMargin, @@ -282,31 +289,22 @@ export function DocumentTimeline({ }} > {item.type === "month" ? ( -

- {formatMonthLabel(item.key, locale)} -

+ ) : ( -
- {item.documents.map((doc) => ( - - ))} -
+ )} ); diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index bc4ad5be6..5f6781dc8 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -993,10 +993,17 @@ export function DocumentsView() { {list.isPending ? (
- {Array.from({ length: 9 }, (_, i) => ( - + {Array.from({ length: 8 }, (_, i) => ( +
+ + + +
))}
) : list.isError ? ( diff --git a/src/components/insights/coach-panel/__tests__/open-at-end.test.ts b/src/components/insights/coach-panel/__tests__/open-at-end.test.ts new file mode 100644 index 000000000..4e1fe1604 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/open-at-end.test.ts @@ -0,0 +1,144 @@ +/** + * A saved conversation opens at its last answer and stays there while late + * layout (charts, tables) grows the thread, until the reader takes over. + * Driven against a fake scroller: the test environment has no layout, and + * the behaviour under test is which scroll the thread asks for, and when. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { OPEN_AT_END_SETTLE_MS, openAtEnd } from "../open-at-end"; + +type Listener = () => void; + +function fakeScroller(children = 3) { + const listeners = new Map>(); + const el = { + scrollHeight: 1000, + children: Array.from({ length: children }, () => ({}) as Element), + scrollTo: vi.fn(), + addEventListener: (type: string, fn: Listener) => { + if (!listeners.has(type)) listeners.set(type, new Set()); + listeners.get(type)!.add(fn); + }, + removeEventListener: (type: string, fn: Listener) => { + listeners.get(type)?.delete(fn); + }, + fire: (type: string) => { + for (const fn of [...(listeners.get(type) ?? [])]) fn(); + }, + listenerCount: () => + [...listeners.values()].reduce((n, set) => n + set.size, 0), + }; + return el; +} + +/** A ResizeObserver whose callback the test fires by hand. */ +function fakeResizeObserver() { + const instances: Array<{ fire: () => void; observed: unknown[] }> = []; + class FakeResizeObserver { + observed: unknown[] = []; + disconnected = false; + constructor(private readonly cb: () => void) { + instances.push(this as never); + } + observe(node: unknown) { + this.observed.push(node); + } + disconnect() { + this.disconnected = true; + } + fire() { + if (!this.disconnected) this.cb(); + } + } + return { + Ctor: FakeResizeObserver as unknown as typeof ResizeObserver, + instances, + }; +} + +beforeEach(() => { + vi.useFakeTimers(); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +describe("openAtEnd", () => { + it("jumps to the end at once, without the smooth animation that lost it", () => { + const el = fakeScroller(); + openAtEnd(el, { ResizeObserver: undefined, MutationObserver: undefined }); + expect(el.scrollTo).toHaveBeenCalledTimes(1); + expect(el.scrollTo).toHaveBeenCalledWith({ + top: 1000, + behavior: "instant", + }); + }); + + it("follows the end when a chart that loads later grows the thread", () => { + const el = fakeScroller(); + const ro = fakeResizeObserver(); + openAtEnd(el, { ResizeObserver: ro.Ctor, MutationObserver: undefined }); + expect(ro.instances[0].observed).toHaveLength(3); + + el.scrollHeight = 1070; // one chart's height, the phone shortfall + ro.instances[0].fire(); + expect(el.scrollTo).toHaveBeenLastCalledWith({ + top: 1070, + behavior: "instant", + }); + }); + + it("lets go the moment the reader scrolls, touches or presses a key", () => { + for (const event of ["wheel", "touchstart", "keydown", "pointerdown"]) { + const el = fakeScroller(); + const ro = fakeResizeObserver(); + openAtEnd(el, { ResizeObserver: ro.Ctor, MutationObserver: undefined }); + el.fire(event); + el.scrollHeight = 2000; + ro.instances[0].fire(); + expect(el.scrollTo, event).toHaveBeenCalledTimes(1); + expect(el.listenerCount(), event).toBe(0); + } + }); + + it("stops following on its own once the layout has had time to settle", () => { + const el = fakeScroller(); + const ro = fakeResizeObserver(); + openAtEnd(el, { ResizeObserver: ro.Ctor, MutationObserver: undefined }); + vi.advanceTimersByTime(OPEN_AT_END_SETTLE_MS); + el.scrollHeight = 3000; + ro.instances[0].fire(); + expect(el.scrollTo).toHaveBeenCalledTimes(1); + expect(el.listenerCount()).toBe(0); + }); + + it("stops when the caller says so, as on switching conversations", () => { + const el = fakeScroller(); + const ro = fakeResizeObserver(); + const stop = openAtEnd(el, { + ResizeObserver: ro.Ctor, + MutationObserver: undefined, + }); + stop(); + ro.instances[0].fire(); + expect(el.scrollTo).toHaveBeenCalledTimes(1); + }); +}); + +describe("the thread wires it to the conversation it opens", () => { + it("runs on a layout effect keyed on the conversation", async () => { + const { readFileSync } = await import("node:fs"); + const { join } = await import("node:path"); + const source = readFileSync( + join( + process.cwd(), + "src/components/insights/coach-panel/message-thread.tsx", + ), + "utf8", + ); + expect(source).toMatch( + /useLayoutEffect\(\(\) => \{[\s\S]*?return openAtEnd\(el\);[\s\S]*?\}, \[conversationId, hasMessages\]\);/, + ); + }); +}); diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index a7f3e82d6..538972036 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -4,6 +4,7 @@ import { Fragment, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -18,6 +19,7 @@ import { ChatBubble, type FollowUpOffer, type ReplyOffer } from "./chat-bubble"; import type { SuggestedReply } from "./suggested-replies"; import { CoachMessageDatesProvider } from "./coach-results"; import { ScrollToBottomButton } from "./scroll-to-bottom-button"; +import { openAtEnd } from "./open-at-end"; import { focusCoachComposer } from "./composer-focus"; import { EMPTY_LIVE_TURN_KEYS, @@ -579,6 +581,21 @@ export function MessageThread({ el.scrollTo({ top: el.scrollHeight, behavior: scrollBehaviorForUser() }); }, []); + // Opening a saved conversation lands on its last answer, and stays there + // while the answers' charts and tables finish loading (see `open-at-end`). + // Keyed on the conversation, so switching to another one re-opens at its + // end even when both have the same number of messages; a layout effect, so + // the first painted frame is already the end rather than the top. + const conversationId = conversation?.id ?? null; + const hasMessages = messages.length > 0; + useLayoutEffect(() => { + const el = scrollerRef.current; + if (!el || !conversationId || !hasMessages) return; + wasPinnedRef.current = true; + setPinned(true); + return openAtEnd(el); + }, [conversationId, hasMessages]); + // Auto-scroll on new messages OR streaming-content growth, but only // when the user was already at the bottom. v1.4.25 W5 — the // optimistic user bubble counts as a new message; scroll on its diff --git a/src/components/insights/coach-panel/open-at-end.ts b/src/components/insights/coach-panel/open-at-end.ts new file mode 100644 index 000000000..e8dc444c9 --- /dev/null +++ b/src/components/insights/coach-panel/open-at-end.ts @@ -0,0 +1,102 @@ +/** + * Open a saved conversation at its end, and keep it there while the answers + * finish laying out. + * + * A conversation opened from the history used to land at the top on a wide + * screen and short of the last answer on a phone. Two things worked against + * the thread's ordinary auto-scroll. It scrolled smoothly, and the scroll + * events of that animation passed through positions that were not at the + * bottom, so the thread concluded the reader had scrolled away and stopped + * following. And the answers' charts and tables load after the messages, so + * the end moved down after the scroll had already finished: about 70 px on a + * phone, one chart's height. + * + * So the opening jump is instant, and for a short while afterwards every + * growth of the thread's content moves the view to the new end. The settling + * stops early the moment the reader does anything that means "let me look + * around" (a wheel, a touch, a key, a pointer press inside the thread), and + * on its own after {@link OPEN_AT_END_SETTLE_MS}. + */ + +/** How long late layout (charts, tables, images) may still move the end. */ +export const OPEN_AT_END_SETTLE_MS = 4000; + +/** Events that mean the reader has taken over the scroll position. */ +const READER_EVENTS = [ + "wheel", + "touchstart", + "keydown", + "pointerdown", +] as const; + +/** The part of an element the settling needs; a real `HTMLElement` fits. */ +export interface OpenAtEndScroller { + readonly scrollHeight: number; + readonly children: ArrayLike; + scrollTo(options: ScrollToOptions): void; + addEventListener(type: string, listener: () => void): void; + removeEventListener(type: string, listener: () => void): void; +} + +/** The observers to use; the browser's by default, fakes in a test. */ +interface Observers { + ResizeObserver?: typeof ResizeObserver; + MutationObserver?: typeof MutationObserver; +} + +/** + * Jump to the end now and follow the end while the content settles. Returns + * the function that stops following; call it on unmount or when another + * conversation opens. + */ +export function openAtEnd( + el: OpenAtEndScroller, + observers: Observers = { + ResizeObserver: globalThis.ResizeObserver, + MutationObserver: globalThis.MutationObserver, + }, +): () => void { + // `instant`, not `auto`: the thread carries `scroll-smooth`, and under it + // `auto` animates, which is exactly the motion that lost the end before. + const toEnd = () => + el.scrollTo({ top: el.scrollHeight, behavior: "instant" }); + toEnd(); + + let stopped = false; + const resize = observers.ResizeObserver + ? new observers.ResizeObserver(() => { + if (!stopped) toEnd(); + }) + : null; + const watch = (node: Element) => resize?.observe(node); + for (const child of Array.from(el.children)) watch(child); + + // A bubble that mounts after the opening (a lazily loaded answer part) + // is watched from then on, and its own arrival moves the end too. + const mutations = observers.MutationObserver + ? new observers.MutationObserver((records) => { + if (stopped) return; + for (const record of records) { + for (const node of Array.from(record.addedNodes)) { + // An element (nodeType 1), not a text node: only elements resize. + if (node.nodeType === 1) watch(node as Element); + } + } + toEnd(); + }) + : null; + mutations?.observe(el as unknown as Node, { childList: true }); + + const stop = () => { + if (stopped) return; + stopped = true; + resize?.disconnect(); + mutations?.disconnect(); + clearTimeout(timer); + for (const type of READER_EVENTS) el.removeEventListener(type, stop); + }; + for (const type of READER_EVENTS) el.addEventListener(type, stop); + const timer = setTimeout(stop, OPEN_AT_END_SETTLE_MS); + + return stop; +} diff --git a/src/components/settings/settings-section-loader.tsx b/src/components/settings/settings-section-loader.tsx new file mode 100644 index 000000000..447a8b387 --- /dev/null +++ b/src/components/settings/settings-section-loader.tsx @@ -0,0 +1,193 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { useEffect, type ComponentType } from "react"; + +import { Skeleton } from "@/components/ui/skeleton"; +import { SettingsCard } from "@/components/settings/settings-card"; +import type { SettingsSectionSlug } from "@/components/settings/section-slugs"; +import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; + +/** + * One settings section, loaded on its own. + * + * The section route renders exactly one of twenty-one sections, but importing + * them statically put all twenty-one (and everything under them: the import + * panel, the integration cards, the MCP and API token tables, the score + * editor) into the route's entry bundle, so opening "About" downloaded the + * whole settings tree. Each section is now its own chunk. A first load still + * renders the section on the server (`ssr` stays on), so nothing jumps; the + * placeholder below only shows while a client-side switch between sections + * fetches the next chunk. + */ + +/** Props a section takes. Only Integrations takes any. */ +interface SectionProps { + callbackUrls?: IntegrationCallbackUrls; +} + +function SectionLoading() { + // The settings card shell itself, so the section does not move when the + // real cards arrive (UI standards §13: a loading state paints the card). + return ( +
+ + + + + + + + + +
+ ); +} + +/** + * One import per section, shared by the lazy component below and by the + * preload. One call site per file keeps the bundler to one chunk per section; + * a second `import()` of the same file elsewhere emitted a second copy. + */ +const LOADERS: Record< + SettingsSectionSlug, + () => Promise> +> = { + account: () => + import("@/components/settings/account-section").then( + (m) => m.AccountSection, + ), + access: () => + import("@/components/settings/access-section").then((m) => m.AccessSection), + security: () => + import("@/components/settings/security-section").then( + (m) => m.SecuritySection, + ), + modules: () => + import("@/components/settings/modules-section").then( + (m) => m.ModulesSection, + ), + about: () => + import("@/components/settings/about-section").then((m) => m.AboutSection), + ai: () => import("@/components/settings/ai-section").then((m) => m.AiSection), + coach: () => + import("@/components/settings/coach-section").then((m) => m.CoachSection), + integrations: () => + import("@/components/settings/integrations-section").then( + (m) => + function IntegrationsWithUrls({ callbackUrls }: SectionProps) { + // The route always passes the server-resolved URLs; the guard + // keeps the section's own prop contract required. + if (!callbackUrls) return null; + return ; + }, + ), + sources: () => + import("@/components/settings/sources-section").then( + (m) => m.SourcesSection, + ), + notifications: () => + import("@/components/settings/notifications-section").then( + (m) => m.NotificationsSection, + ), + layout: () => + import("@/components/settings/layout-section").then((m) => m.LayoutSection), + environment: () => + import("@/components/settings/environment-section").then( + (m) => m.EnvironmentSection, + ), + anamnesis: () => + import("@/components/settings/anamnesis-section").then( + (m) => m.AnamnesisSection, + ), + score: () => + import("@/components/settings/score-section").then((m) => m.ScoreSection), + thresholds: () => + import("@/components/settings/thresholds-section").then( + (m) => m.ThresholdsSection, + ), + api: () => + import("@/components/settings/api-section").then((m) => m.ApiSection), + mcp: () => + import("@/components/settings/mcp-section").then((m) => m.McpSection), + gesundheitsakte: () => + import("@/components/settings/gesundheitsakte-section").then( + (m) => m.GesundheitsakteSection, + ), + export: () => + import("@/components/settings/export-section").then((m) => m.ExportSection), + advanced: () => + import("@/components/settings/advanced-section").then( + (m) => m.AdvancedSection, + ), + privacy: () => + import("@/components/settings/privacy-section").then( + (m) => m.PrivacySection, + ), +}; + +const SECTIONS: Record> = { + account: dynamic(LOADERS.account, { loading: () => }), + access: dynamic(LOADERS.access, { loading: () => }), + security: dynamic(LOADERS.security, { loading: () => }), + modules: dynamic(LOADERS.modules, { loading: () => }), + about: dynamic(LOADERS.about, { loading: () => }), + ai: dynamic(LOADERS.ai, { loading: () => }), + coach: dynamic(LOADERS.coach, { loading: () => }), + integrations: dynamic(LOADERS.integrations, { + loading: () => , + }), + sources: dynamic(LOADERS.sources, { loading: () => }), + notifications: dynamic(LOADERS.notifications, { + loading: () => , + }), + layout: dynamic(LOADERS.layout, { loading: () => }), + environment: dynamic(LOADERS.environment, { + loading: () => , + }), + anamnesis: dynamic(LOADERS.anamnesis, { loading: () => }), + score: dynamic(LOADERS.score, { loading: () => }), + thresholds: dynamic(LOADERS.thresholds, { + loading: () => , + }), + api: dynamic(LOADERS.api, { loading: () => }), + mcp: dynamic(LOADERS.mcp, { loading: () => }), + gesundheitsakte: dynamic(LOADERS.gesundheitsakte, { + loading: () => , + }), + export: dynamic(LOADERS.export, { loading: () => }), + advanced: dynamic(LOADERS.advanced, { loading: () => }), + privacy: dynamic(LOADERS.privacy, { loading: () => }), +}; + +/** + * The section mounts only once the record gate has the account + * (`RecordSettingsSectionGate`), so without a head start its chunk would be + * fetched after that and the reader would see two loading states in a row. + * This starts the fetch at hydration, beside the gate's own read; the module + * cache makes the later lazy render of the same file free. + */ +export function SettingsSectionPreload({ + section, +}: { + section: SettingsSectionSlug; +}) { + useEffect(() => { + void LOADERS[section]().catch(() => { + // The section's own lazy import retries and reports; a failed head + // start changes nothing. + }); + }, [section]); + return null; +} + +export function SettingsSectionLoader({ + section, + callbackUrls, +}: { + section: SettingsSectionSlug; + callbackUrls?: IntegrationCallbackUrls; +}) { + const Section = SECTIONS[section]; + return
; +} diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 9a85faa76..09b6c7f1a 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -37,7 +37,7 @@ import { settleRefusedRecordSessionTransition, } from "@/lib/query-keys/record-session-transition"; import type { AccountAccess } from "@/lib/sharing/account-access-view"; -import { parseAccountAccess } from "@/lib/sharing/account-access-schema"; +import { parseAccountAccess } from "@/lib/sharing/account-access-parse"; /** * v1.36.0 — an account with no sharing at all. The shape `fetchMe` falls back diff --git a/src/lib/jobs/measurement-maintenance-status.ts b/src/lib/jobs/measurement-maintenance-status.ts new file mode 100644 index 000000000..f611a025b --- /dev/null +++ b/src/lib/jobs/measurement-maintenance-status.ts @@ -0,0 +1,129 @@ +/** + * What the admin card shows about the measurement table maintenance: the + * newest run and where it stands, whether the compaction-tombstone purge is + * still at work (the run refuses to start while it is), and the table's size + * right now. + * + * Read straight from `pgboss.job`, the same source `job-failures.ts` reads, + * and fail-soft the same way: a web-only deployment has no `pgboss` schema, + * and the card says "no background worker" instead of failing. + */ +import { prisma } from "@/lib/db"; +import { MEASUREMENT_MAINTENANCE_QUEUE } from "@/lib/jobs/measurement-maintenance"; + +const PURGE_QUEUE = "compaction-tombstone-purge"; + +/** Where the newest run stands, in the words the card uses. */ +export type MaintenanceRunState = "queued" | "running" | "completed" | "failed"; + +/** + * How a finished run ended. `completed` did the work; the two refusals + * finished without touching the table. + */ +export type MaintenanceRunOutcome = + "completed" | "refused_purge_running" | "already_running"; + +export interface MeasurementMaintenanceStatus { + /** False when there is no background queue to ask (web-only deployment). */ + available: boolean; + /** The purge is queued or running; a new run would refuse to start. */ + purgePending: boolean; + run: { + state: MaintenanceRunState; + requestedAt: string; + startedAt: string | null; + finishedAt: string | null; + /** Only for `completed`: what the finished run actually did. */ + outcome: MaintenanceRunOutcome | null; + } | null; + /** `measurements` with its indexes and TOAST, and its indexes alone. */ + sizes: { tableBytes: number; indexBytes: number } | null; +} + +const OUTCOMES: ReadonlySet = new Set([ + "completed", + "refused_purge_running", + "already_running", +]); + +/** pg-boss's row state → the card's run state. */ +export function runStateOf(state: string): MaintenanceRunState { + if (state === "created" || state === "retry") return "queued"; + if (state === "active") return "running"; + if (state === "completed") return "completed"; + return "failed"; +} + +async function readSizes(): Promise { + try { + const rows = await prisma.$queryRaw< + Array<{ table_bytes: bigint; index_bytes: bigint }> + >` + SELECT pg_total_relation_size('measurements'::regclass) AS table_bytes, + pg_indexes_size('measurements'::regclass) AS index_bytes + `; + const row = rows[0]; + if (!row) return null; + return { + tableBytes: Number(row.table_bytes), + indexBytes: Number(row.index_bytes), + }; + } catch { + return null; + } +} + +export async function readMeasurementMaintenanceStatus(): Promise { + const sizes = await readSizes(); + try { + const [runs, purge] = await Promise.all([ + prisma.$queryRaw< + Array<{ + state: string; + created_on: Date; + started_on: Date | null; + completed_on: Date | null; + outcome: string | null; + }> + >` + SELECT state, created_on, started_on, completed_on, + output->'did'->>'outcome' AS outcome + FROM pgboss.job + WHERE name = ${MEASUREMENT_MAINTENANCE_QUEUE} + ORDER BY created_on DESC + LIMIT 1 + `, + prisma.$queryRaw>` + SELECT count(*)::int AS n + FROM pgboss.job + WHERE name = ${PURGE_QUEUE} + AND state IN ('created', 'retry', 'active') + `, + ]); + const row = runs[0]; + const state = row ? runStateOf(row.state) : null; + return { + available: true, + purgePending: (purge[0]?.n ?? 0) > 0, + run: + row && state + ? { + state, + requestedAt: row.created_on.toISOString(), + startedAt: row.started_on?.toISOString() ?? null, + finishedAt: row.completed_on?.toISOString() ?? null, + outcome: + state === "completed" && + row.outcome !== null && + OUTCOMES.has(row.outcome) + ? (row.outcome as MaintenanceRunOutcome) + : null, + } + : null, + sizes, + }; + } catch { + // No `pgboss` schema (web-only deployment) or no permission to read it. + return { available: false, purgePending: false, run: null, sizes }; + } +} diff --git a/src/lib/measurements/display-transform.ts b/src/lib/measurements/display-transform.ts index 72b7426d1..ff5c371b9 100644 --- a/src/lib/measurements/display-transform.ts +++ b/src/lib/measurements/display-transform.ts @@ -66,7 +66,7 @@ import { resolveGlucoseUnit, type GlucoseUnit, } from "@/lib/glucose"; -import { getUnitForType } from "@/lib/validations/measurement"; +import { getUnitForType } from "@/lib/measurements/unit-map"; export type UnitPreference = "metric" | "imperial"; diff --git a/src/lib/measurements/unit-map.ts b/src/lib/measurements/unit-map.ts new file mode 100644 index 000000000..307a7173d --- /dev/null +++ b/src/lib/measurements/unit-map.ts @@ -0,0 +1,163 @@ +/** + * The canonical storage unit of every measurement type. + * + * Its own module, with no imports, because both ends read it: the write + * validation in `validations/measurement.ts` and the client's display + * transform (`display-transform.ts`), which runs on nearly every chart and + * tile. Living inside the validation module it dragged Zod into each of those + * routes for one table lookup. `validations/measurement.ts` re-exports + * `getUnitForType`, so server callers keep their import. + */ + +const unitMap: Record = { + WEIGHT: "kg", + BLOOD_PRESSURE_SYS: "mmHg", + BLOOD_PRESSURE_DIA: "mmHg", + PULSE: "bpm", + BODY_FAT: "%", + // v1.4.23 — sleep duration shifted from hours to minutes so HealthKit + // category-sample stages can be stored without precision loss. Older + // surfaces that need hours convert at read time (`minutes / 60`). + SLEEP_DURATION: "minutes", + ACTIVITY_STEPS: "steps", + BLOOD_GLUCOSE: "mg/dL", + TOTAL_BODY_WATER: "kg", + BONE_MASS: "kg", + OXYGEN_SATURATION: "%", + // ── v1.4.23 Apple Health canonical units ── + HEART_RATE_VARIABILITY: "ms", + RESTING_HEART_RATE: "bpm", + ACTIVE_ENERGY_BURNED: "kcal", + FLIGHTS_CLIMBED: "flights", + WALKING_RUNNING_DISTANCE: "m", + VO2_MAX: "mL/(kg·min)", + BODY_TEMPERATURE: "celsius", + // ── v1.4.25 W5d Withings full coverage ── + FAT_FREE_MASS: "kg", + FAT_MASS: "kg", + MUSCLE_MASS: "kg", + // Distinct from BODY_TEMPERATURE — surface temps run ~32 °C; sharing + // the bucket would corrupt analytics. Same canonical unit (°C). + SKIN_TEMPERATURE: "celsius", + PULSE_WAVE_VELOCITY: "m/s", + VASCULAR_AGE: "years", + // Withings reports visceral fat as a 1–12 rating, not a percent. The + // string mirrors what Withings prints in Health Mate. + VISCERAL_FAT: "rating", + // ── v1.4.25 W8d Apple Health server-prep ── + // Sound-pressure level — A-weighted decibels (dBA). HealthKit reports + // both audio-exposure metrics in dBASPL; we store the unweighted "dBA" + // label because the A-weighting is implicit (every HealthKit audio + // sample carries it). 30 dBA = quiet bedroom; 140 dBA = pain threshold. + AUDIO_EXPOSURE_ENV: "dBA", + AUDIO_EXPOSURE_HEADPHONE: "dBA", + // Daily-rollup pattern (one sample = one day's outdoor-light minutes). + // 0–1440 covers the 24-hour day; in practice indoor users sit near 0 + // and outdoor athletes accumulate a few hours. + TIME_IN_DAYLIGHT: "minutes", + // ── v1.5.5 iOS-coord additions ── + // Respiratory rate breaths-per-minute — the HK identifier ships as + // `count/min`; we keep the more conventional clinical label. + RESPIRATORY_RATE: "breaths/min", + // BMI kg/m² — HealthKit ships the unitless ratio; the canonical + // display string mirrors clinical convention. + BODY_MASS_INDEX: "kg/m²", + // Lean body mass kg — body-composition partner to FAT_MASS. + LEAN_BODY_MASS: "kg", + // Walking heart rate average bpm — daily rollup; distinct from + // RESTING_HEART_RATE (sleep-window minimum) and spot PULSE. + WALKING_HEART_RATE_AVERAGE: "bpm", + // Walking gait percent (0-100 after server-side ×100 scaling). + // Same convention as WALKING_STEADINESS / BODY_FAT / OXYGEN_SATURATION + // — see the project convention block in `apple-health-mapping.ts`. + WALKING_ASYMMETRY: "%", + WALKING_DOUBLE_SUPPORT: "%", + // ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ── + // Step length is metres; speed is metres per second. Both flow + // raw on the wire — no server-side scaling. The unit strings + // match HealthKit's `m` / `m/s` defaults. + WALKING_STEP_LENGTH: "m", + WALKING_SPEED: "m/s", + // ── v1.10.0 — additive HealthKit signals (WX-A) ── + // Cardio recovery is the bpm drop one minute after peak exercise. + CARDIO_RECOVERY: "bpm", + // Overnight wrist temperature in °C (absolute reading; Apple's own + // display frames it as a baseline deviation, we store the reading). + WRIST_TEMPERATURE: "celsius", + // Hard-fall detections — a plain count. + FALL_COUNT: "count", + // Apple's estimated six-minute-walk-test distance in metres. + SIX_MINUTE_WALK_DISTANCE: "m", + // Stair gait speeds — raw metres-per-second (no scaling). + STAIR_ASCENT_SPEED: "m/s", + STAIR_DESCENT_SPEED: "m/s", + // Per-night breathing-disturbance index — a unitless count Apple + // classifies as NotElevated / Elevated. + BREATHING_DISTURBANCES: "count", + // ── v1.10.0 — categorical events (WX-B) ── + // EVENT rows are dimensionless occurrences (value is always 1). The + // canonical unit is the bare "event" so any accidental numeric surfacing + // reads sensibly; the awareness timeline never displays the value. + IRREGULAR_RHYTHM_NOTIFICATION: "event", + HIGH_HEART_RATE_EVENT: "event", + LOW_HEART_RATE_EVENT: "event", + WALKING_STEADINESS_EVENT: "event", + BREATHING_DISTURBANCE_EVENT: "event", + // ── v1.10.0 — computed scores (WX-C) ── + // Server-derived 0–100 wellness scores. The canonical unit is the bare + // "score" so the value reads sensibly anywhere it surfaces. + RECOVERY_SCORE: "score", + STRESS_SCORE: "score", + STRAIN_SCORE: "score", + // ── v1.11.0 — WHOOP-native score classes ── + // RMSSD HRV is in milliseconds, same canonical unit as the SDNN + // HEART_RATE_VARIABILITY (different estimator, same dimension). + HRV_RMSSD: "ms", + // Day / workout strain ride WHOOP's bounded 0–21 scale; the bare "score" + // unit reads sensibly wherever the value surfaces (distinct from the + // 0–100 COMPUTED STRAIN_SCORE). + DAY_STRAIN: "score", + WORKOUT_STRAIN: "score", + // Sleep quality percentages (0–100). + SLEEP_PERFORMANCE: "%", + SLEEP_EFFICIENCY: "%", + SLEEP_CONSISTENCY: "%", + // Recommended sleep duration in minutes (WHOOP reports ms; mapper ÷60000). + SLEEP_NEED: "minutes", + // Day energy expenditure in kilojoules (WHOOP-native; kept in kJ so the + // device value round-trips rather than being converted to kcal). + ENERGY_EXPENDITURE_KJ: "kJ", + // ── v1.12.8 — WHOOP cycle + sleep coverage completion ── + // Daily average / max heart rate bpm — whole-cycle aggregates, distinct + // from the spot PULSE / RESTING_HEART_RATE / WALKING_HEART_RATE_AVERAGE. + AVERAGE_HEART_RATE: "bpm", + MAX_HEART_RATE: "bpm", + // Per-night sleep disturbance tally — a plain integer count. + SLEEP_DISTURBANCE_COUNT: "count", + // ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ── + ANS_CHARGE: "score", + CARDIO_LOAD: "score", + // ── v1.17.1 — Oura coverage completion ── + // Oura's headline 0–100 Sleep Score — bare "score" like the other 0–100 scores. + SLEEP_SCORE: "score", + // Signed body-temperature deviation in °C (Oura nightly baseline offset). + BODY_TEMPERATURE_DEVIATION: "celsius", + // ── v1.19.0 — Oura resilience ── + // Ordinal level scale (1=limited … 5=exceptional) — the categorical band + // encoded into the numeric value. See RESILIENCE_LEVELS in src/lib/oura/client. + RESILIENCE: "level", + // ── v1.25 — clinical-signals wave ── + PHQ9_SCORE: "score", + GAD7_SCORE: "score", + // ── v1.27.9 — screening scores ── + WHO5_SCORE: "score", + SCI_SCORE: "score", + GRIP_STRENGTH: "kg", + PAIN_NRS: "score", + WAIST_CIRCUMFERENCE: "cm", + WAIST_TO_HEIGHT: "ratio", +}; + +export function getUnitForType(type: string): string { + return unitMap[type] ?? "unknown"; +} diff --git a/src/lib/openapi/routes/admin.ts b/src/lib/openapi/routes/admin.ts index b32c4f87c..3c9079df7 100644 --- a/src/lib/openapi/routes/admin.ts +++ b/src/lib/openapi/routes/admin.ts @@ -275,8 +275,69 @@ const adminMeasurementMaintenanceResult = z }) .meta({ id: "AdminMeasurementMaintenanceResult" }); +const adminMeasurementMaintenanceStatus = z + .object({ + available: z + .boolean() + .describe( + "False when there is no background queue to ask (a web-only deployment); nothing can be started then.", + ), + purgePending: z + .boolean() + .describe( + "The compaction-tombstone purge is queued or running. A run started now finishes at once with `refused_purge_running`.", + ), + run: z + .object({ + state: z.enum(["queued", "running", "completed", "failed"]), + requestedAt: z.iso.datetime(), + startedAt: z.iso.datetime().nullable(), + finishedAt: z.iso.datetime().nullable(), + outcome: z + .enum(["completed", "refused_purge_running", "already_running"]) + .nullable() + .describe( + "How a `completed` run ended: `completed` did the work, the two refusals finished without touching the table. Null for any other state.", + ), + }) + .nullable() + .describe( + "The newest run pg-boss still keeps (terminal rows live seven days), or null when there is none.", + ), + sizes: z + .object({ + tableBytes: z + .number() + .int() + .describe("`measurements` with its indexes and TOAST."), + indexBytes: z.number().int().describe("Its indexes alone."), + }) + .nullable(), + }) + .meta({ id: "AdminMeasurementMaintenanceStatus" }); + export const adminDiagnosticPaths: NonNullable = { "/api/admin/maintenance/measurements": { + get: { + tags: ["Admin"], + summary: "Where measurement table maintenance stands", + description: + "The newest maintenance run and its state, whether the compaction-tombstone purge is still at work, and the table's size right now. The admin card polls it while a run is queued or running. Cookie auth only: `requireAdmin()` refuses every Bearer caller.", + responses: { + "200": { + description: "The status.", + content: { + "application/json": { + schema: dataEnvelope( + adminMeasurementMaintenanceStatus, + "AdminMeasurementMaintenanceStatusResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, post: { tags: ["Admin"], summary: "Queue measurement table maintenance", diff --git a/src/lib/query-keys/admin.ts b/src/lib/query-keys/admin.ts index 23b9603ad..59b402bb4 100644 --- a/src/lib/query-keys/admin.ts +++ b/src/lib/query-keys/admin.ts @@ -48,6 +48,8 @@ export const adminKeys = { adminBackupRestores: () => ["admin", "backup-restores"] as const, /** v1.23 — encryption coverage + rotation progress (admin Encryption section). */ adminEncryptionStatus: () => ["admin", "encryption", "status"] as const, + adminMeasurementMaintenance: () => + ["admin", "maintenance", "measurements"] as const, /** v1.40.0 — the "Back up your encryption key" step (admin + dashboard banner). */ adminEncryptionKeyBackup: () => ["admin", "encryption", "key-backup"] as const, diff --git a/src/lib/sharing/__tests__/account-access-schema.test.ts b/src/lib/sharing/__tests__/account-access-parse.test.ts similarity index 66% rename from src/lib/sharing/__tests__/account-access-schema.test.ts rename to src/lib/sharing/__tests__/account-access-parse.test.ts index 16170236b..825a13c2d 100644 --- a/src/lib/sharing/__tests__/account-access-schema.test.ts +++ b/src/lib/sharing/__tests__/account-access-parse.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { parseAccountAccess } from "../account-access-schema"; +import { + parseAccountAccess, + validateAccountAccessBlock, +} from "../account-access-parse"; +import { accountAccessBlockSchema } from "../account-access-schema"; const sharedEntry = { accountId: "record-a", @@ -220,3 +224,108 @@ describe("parseAccountAccess", () => { ).toBeNull(); }); }); + +/** + * The browser parse carries no Zod (it rides the shell every route shares), + * so it is a second statement of the published schema. This holds the two to + * one verdict: over every fixture below, and every single-field breakage of + * each, the hand-written check accepts exactly what the schema accepts and + * returns the same stripped value. Change one without the other and this + * fails with the input that tells them apart. + */ +describe("validateAccountAccessBlock agrees with the published schema", () => { + const BAD_VALUES: unknown[] = [ + undefined, + null, + 0, + 1, + true, + "", + "bogus", + [], + ["labs"], + ["labs", "labs"], + ["nope"], + {}, + ]; + + const managerEntry = { + ...sharedEntry, + accountId: "record-m", + username: "record-m", + access: "write", + level: "manage", + recordKind: "managed", + sections: null, + canWrite: true, + writableDomains: ["measurements", "mind"], + manageableDomains: ["measurements"], + }; + const writerEntry = { + ...sharedEntry, + accountId: "record-w", + username: "record-w", + access: "write", + level: "write", + sections: ["labs", "measurements"], + canWrite: true, + writableDomains: ["measurements"], + }; + + const bases: Record[] = [ + accessBlock(), + { accounts: [], active: null, recordKind: "self", canSwitch: false }, + { + accounts: [managerEntry, writerEntry], + active: null, + recordKind: "self", + canSwitch: true, + }, + { + accounts: [writerEntry], + active: { ...writerEntry, extra: "stripped" }, + recordKind: "shared", + canSwitch: true, + }, + ]; + + function variants(): unknown[] { + const out: unknown[] = [...bases, null, "x", [], 42]; + for (const base of bases) { + for (const key of Object.keys(base)) { + for (const bad of BAD_VALUES) out.push({ ...base, [key]: bad }); + } + const accounts = base.accounts as Record[]; + accounts.forEach((entry, index) => { + for (const key of [...Object.keys(entry), "unknownKey"]) { + for (const bad of BAD_VALUES) { + const broken = { ...entry, [key]: bad }; + const next = [...accounts]; + next[index] = broken; + out.push({ ...base, accounts: next }); + if (base.active) out.push({ ...base, active: broken }); + } + } + }); + } + return out; + } + + it("accepts and rejects the same blocks, with the same output", () => { + const corpus = variants(); + expect(corpus.length).toBeGreaterThan(500); + let accepted = 0; + for (const input of corpus) { + const zod = accountAccessBlockSchema.safeParse(input); + const mine = validateAccountAccessBlock(input); + expect(mine !== null, JSON.stringify(input)).toBe(zod.success); + if (zod.success) { + expect(mine, JSON.stringify(input)).toEqual(zod.data); + accepted += 1; + } + } + // Both arms exercised: the corpus is not all-reject or all-accept. + expect(accepted).toBeGreaterThan(10); + expect(accepted).toBeLessThan(corpus.length - 100); + }); +}); diff --git a/src/lib/sharing/account-access-parse.ts b/src/lib/sharing/account-access-parse.ts new file mode 100644 index 000000000..162409394 --- /dev/null +++ b/src/lib/sharing/account-access-parse.ts @@ -0,0 +1,212 @@ +/** + * The browser's parse of the account-access block on `GET /api/auth/me`. + * + * Plain TypeScript, no Zod, on purpose. `use-auth.ts` runs this parse on every + * app boot, so whatever it imports lands in the shell every route shares, and + * this parse was the shell's only Zod import: about 60 KB gzip on every first + * paint for one structural check. A tree-shaken `zod/mini` build measured + * almost the same under Turbopack, because the shared core comes along either + * way. + * + * The Zod schema stays the contract: `account-access-schema.ts` publishes it + * to OpenAPI, and `account-access-parse.test.ts` runs both over the same + * corpus of good and broken blocks and fails the moment they disagree on + * whether a block is accepted or on what comes out of it. + */ +import type { + AccountAccess, + AccountAccessEntry, +} from "@/lib/sharing/account-access-view"; +import { SHARE_DOMAINS, type ShareDomain } from "@/lib/sharing/scope"; + +const DOMAINS: ReadonlySet = new Set(SHARE_DOMAINS); + +type Raw = Record; + +function isRecord(value: unknown): value is Raw { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length >= 1; +} + +function isNullableString(value: unknown): value is string | null { + return value === null || typeof value === "string"; +} + +function domainList(value: unknown): ShareDomain[] | null { + if (!Array.isArray(value)) return null; + for (const domain of value) { + if (typeof domain !== "string" || !DOMAINS.has(domain)) return null; + } + return [...(value as ShareDomain[])]; +} + +function hasRepeat(domains: readonly string[]): boolean { + return new Set(domains).size !== domains.length; +} + +function isSubset( + inner: readonly string[], + outer: readonly string[] | null, +): boolean { + if (outer === null) return true; + const set = new Set(outer); + return inner.every((domain) => set.has(domain)); +} + +/** + * One entry, checked field by field and copied out with only the fields the + * contract names (unknown keys are dropped, as the schema strips them), or + * null. The invariants after the field checks are the schema's refinements. + */ +function parseEntry(value: unknown): AccountAccessEntry | null { + if (!isRecord(value)) return null; + const { + accountId, + username, + displayName, + fullName, + access, + level, + recordKind, + sections: rawSections, + canWrite, + writableDomains: rawWritable, + manageableDomains: rawManageable, + } = value; + if (!isNonEmptyString(accountId) || !isNonEmptyString(username)) return null; + if (!isNullableString(displayName) || !isNullableString(fullName)) { + return null; + } + if (access !== "read" && access !== "write") return null; + if (level !== "read" && level !== "write" && level !== "manage") return null; + if (recordKind !== "shared" && recordKind !== "managed") return null; + const sections = rawSections === null ? null : domainList(rawSections); + if (rawSections !== null && sections === null) return null; + if (typeof canWrite !== "boolean") return null; + const writableDomains = domainList(rawWritable); + const manageableDomains = domainList(rawManageable); + if (writableDomains === null || manageableDomains === null) return null; + + const isManage = level === "manage"; + if (sections !== null && hasRepeat(sections)) return null; + if (isManage && (access !== "write" || sections !== null)) return null; + if (canWrite !== (level !== "read")) return null; + if (!isManage && access !== level) return null; + if (hasRepeat(writableDomains) || hasRepeat(manageableDomains)) return null; + if ( + !isSubset(writableDomains, sections) || + !isSubset(manageableDomains, sections) + ) { + return null; + } + if (!isSubset(manageableDomains, writableDomains)) return null; + if (level === "read" && writableDomains.length > 0) return null; + if (!isManage && manageableDomains.length > 0) return null; + + return { + accountId, + username, + displayName, + fullName, + access, + level, + recordKind, + sections, + canWrite, + writableDomains, + manageableDomains, + }; +} + +/** + * The block's structure, without the cross-entry checks: what the schema + * accepts, and the same stripped value it returns. Exported for the + * equivalence test; the app calls {@link parseAccountAccess}. + */ +export function validateAccountAccessBlock( + value: unknown, +): AccountAccess | null { + if (!isRecord(value)) return null; + const { + accounts: rawAccounts, + active: rawActive, + recordKind, + canSwitch, + } = value; + if (!Array.isArray(rawAccounts)) return null; + const accounts: AccountAccessEntry[] = []; + for (const raw of rawAccounts) { + const entry = parseEntry(raw); + if (!entry) return null; + accounts.push(entry); + } + const active = rawActive === null ? null : parseEntry(rawActive); + if (rawActive !== null && active === null) return null; + if ( + recordKind !== "self" && + recordKind !== "shared" && + recordKind !== "managed" + ) { + return null; + } + if (typeof canSwitch !== "boolean") return null; + return { accounts, active, recordKind, canSwitch }; +} + +/** Both published views of the active record must name the same resolved grant. */ +function sameEntry(a: AccountAccessEntry, b: AccountAccessEntry): boolean { + const sameList = (x: readonly string[], y: readonly string[]) => + x.length === y.length && x.every((domain, index) => domain === y[index]); + const sameSections = + a.sections === null || b.sections === null + ? a.sections === b.sections + : sameList(a.sections, b.sections); + return ( + sameList(a.writableDomains, b.writableDomains) && + sameList(a.manageableDomains, b.manageableDomains) && + a.accountId === b.accountId && + a.username === b.username && + a.displayName === b.displayName && + a.fullName === b.fullName && + a.access === b.access && + a.level === b.level && + a.recordKind === b.recordKind && + a.canWrite === b.canWrite && + sameSections + ); +} + +/** Parse the server-resolved access block as a closed presentation contract. */ +export function parseAccountAccess(value: unknown): AccountAccess | null { + const block = validateAccountAccessBlock(value); + if (!block) return null; + + const { accounts, active, recordKind, canSwitch } = block; + const accountIds = new Set(accounts.map((entry) => entry.accountId)); + if ( + accountIds.size !== accounts.length || + canSwitch !== accounts.length > 0 + ) { + return null; + } + + if (active === null) { + return recordKind === "self" + ? { accounts, active: null, recordKind, canSwitch } + : null; + } + + if (!accountIds.has(active.accountId) || recordKind !== active.recordKind) { + return null; + } + + const canonicalActive = accounts.find( + (entry) => entry.accountId === active.accountId, + ); + if (!canonicalActive || !sameEntry(active, canonicalActive)) return null; + + return { accounts, active: canonicalActive, recordKind, canSwitch }; +} diff --git a/src/lib/sharing/account-access-schema.ts b/src/lib/sharing/account-access-schema.ts index 76e5cb060..606a7deb1 100644 --- a/src/lib/sharing/account-access-schema.ts +++ b/src/lib/sharing/account-access-schema.ts @@ -1,6 +1,11 @@ +/** + * The account-access block's contract as a Zod schema, for the OpenAPI + * document. The browser does not import it: its parse is the Zod-free + * `account-access-parse.ts`, kept out of the shell every route shares, and + * `account-access-parse.test.ts` holds the two to the same verdicts. + */ import { z } from "zod/v4"; -import type { AccountAccess } from "./account-access-view"; import { SHARE_DOMAINS } from "./scope"; const accountAccessLevelSchema = z.enum(["read", "write", "manage"]); @@ -21,7 +26,7 @@ function isSubset( return inner.every((domain) => set.has(domain)); } -/** The browser's resolved account-access payload, shared with OpenAPI. */ +/** One account-access entry, as OpenAPI publishes it. */ export const accountAccessEntrySchema = z .object({ accountId: z.string().min(1), @@ -125,73 +130,3 @@ export const accountAccessBlockSchema = z.object({ recordKind: z.enum(["self", "shared", "managed"]), canSwitch: z.boolean(), }); - -/** Both published views of the active record must name the same resolved grant. */ -function matchesCanonicalAccountAccessEntry( - active: z.infer, - canonical: z.infer, -): boolean { - const activeSections = active.sections; - const canonicalSections = canonical.sections; - const sameSections = - activeSections === null || canonicalSections === null - ? activeSections === canonicalSections - : activeSections.length === canonicalSections.length && - activeSections.every( - (section, index) => section === canonicalSections[index], - ); - - const sameList = (a: readonly string[], b: readonly string[]) => - a.length === b.length && a.every((domain, index) => domain === b[index]); - - return ( - sameList(active.writableDomains, canonical.writableDomains) && - sameList(active.manageableDomains, canonical.manageableDomains) && - active.accountId === canonical.accountId && - active.username === canonical.username && - active.displayName === canonical.displayName && - active.fullName === canonical.fullName && - active.access === canonical.access && - active.level === canonical.level && - active.recordKind === canonical.recordKind && - active.canWrite === canonical.canWrite && - sameSections - ); -} - -/** Parse the server-resolved access block as a closed presentation contract. */ -export function parseAccountAccess(value: unknown): AccountAccess | null { - const parsed = accountAccessBlockSchema.safeParse(value); - if (!parsed.success) return null; - - const { accounts, active, recordKind, canSwitch } = parsed.data; - const accountIds = new Set(accounts.map((entry) => entry.accountId)); - if ( - accountIds.size !== accounts.length || - canSwitch !== accounts.length > 0 - ) { - return null; - } - - if (active === null) { - return recordKind === "self" - ? { accounts, active: null, recordKind, canSwitch } - : null; - } - - if (!accountIds.has(active.accountId) || recordKind !== active.recordKind) { - return null; - } - - const canonicalActive = accounts.find( - (entry) => entry.accountId === active.accountId, - ); - if ( - !canonicalActive || - !matchesCanonicalAccountAccessEntry(active, canonicalActive) - ) { - return null; - } - - return { accounts, active: canonicalActive, recordKind, canSwitch }; -} diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 0f68fe9f3..0659540e8 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -298,158 +298,9 @@ export const USER_CORRECTABLE_MEASUREMENT_SOURCES = [ EXTERNAL_SOURCE, ] as const; -const unitMap: Record = { - WEIGHT: "kg", - BLOOD_PRESSURE_SYS: "mmHg", - BLOOD_PRESSURE_DIA: "mmHg", - PULSE: "bpm", - BODY_FAT: "%", - // v1.4.23 — sleep duration shifted from hours to minutes so HealthKit - // category-sample stages can be stored without precision loss. Older - // surfaces that need hours convert at read time (`minutes / 60`). - SLEEP_DURATION: "minutes", - ACTIVITY_STEPS: "steps", - BLOOD_GLUCOSE: "mg/dL", - TOTAL_BODY_WATER: "kg", - BONE_MASS: "kg", - OXYGEN_SATURATION: "%", - // ── v1.4.23 Apple Health canonical units ── - HEART_RATE_VARIABILITY: "ms", - RESTING_HEART_RATE: "bpm", - ACTIVE_ENERGY_BURNED: "kcal", - FLIGHTS_CLIMBED: "flights", - WALKING_RUNNING_DISTANCE: "m", - VO2_MAX: "mL/(kg·min)", - BODY_TEMPERATURE: "celsius", - // ── v1.4.25 W5d Withings full coverage ── - FAT_FREE_MASS: "kg", - FAT_MASS: "kg", - MUSCLE_MASS: "kg", - // Distinct from BODY_TEMPERATURE — surface temps run ~32 °C; sharing - // the bucket would corrupt analytics. Same canonical unit (°C). - SKIN_TEMPERATURE: "celsius", - PULSE_WAVE_VELOCITY: "m/s", - VASCULAR_AGE: "years", - // Withings reports visceral fat as a 1–12 rating, not a percent. The - // string mirrors what Withings prints in Health Mate. - VISCERAL_FAT: "rating", - // ── v1.4.25 W8d Apple Health server-prep ── - // Sound-pressure level — A-weighted decibels (dBA). HealthKit reports - // both audio-exposure metrics in dBASPL; we store the unweighted "dBA" - // label because the A-weighting is implicit (every HealthKit audio - // sample carries it). 30 dBA = quiet bedroom; 140 dBA = pain threshold. - AUDIO_EXPOSURE_ENV: "dBA", - AUDIO_EXPOSURE_HEADPHONE: "dBA", - // Daily-rollup pattern (one sample = one day's outdoor-light minutes). - // 0–1440 covers the 24-hour day; in practice indoor users sit near 0 - // and outdoor athletes accumulate a few hours. - TIME_IN_DAYLIGHT: "minutes", - // ── v1.5.5 iOS-coord additions ── - // Respiratory rate breaths-per-minute — the HK identifier ships as - // `count/min`; we keep the more conventional clinical label. - RESPIRATORY_RATE: "breaths/min", - // BMI kg/m² — HealthKit ships the unitless ratio; the canonical - // display string mirrors clinical convention. - BODY_MASS_INDEX: "kg/m²", - // Lean body mass kg — body-composition partner to FAT_MASS. - LEAN_BODY_MASS: "kg", - // Walking heart rate average bpm — daily rollup; distinct from - // RESTING_HEART_RATE (sleep-window minimum) and spot PULSE. - WALKING_HEART_RATE_AVERAGE: "bpm", - // Walking gait percent (0-100 after server-side ×100 scaling). - // Same convention as WALKING_STEADINESS / BODY_FAT / OXYGEN_SATURATION - // — see the project convention block in `apple-health-mapping.ts`. - WALKING_ASYMMETRY: "%", - WALKING_DOUBLE_SUPPORT: "%", - // ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ── - // Step length is metres; speed is metres per second. Both flow - // raw on the wire — no server-side scaling. The unit strings - // match HealthKit's `m` / `m/s` defaults. - WALKING_STEP_LENGTH: "m", - WALKING_SPEED: "m/s", - // ── v1.10.0 — additive HealthKit signals (WX-A) ── - // Cardio recovery is the bpm drop one minute after peak exercise. - CARDIO_RECOVERY: "bpm", - // Overnight wrist temperature in °C (absolute reading; Apple's own - // display frames it as a baseline deviation, we store the reading). - WRIST_TEMPERATURE: "celsius", - // Hard-fall detections — a plain count. - FALL_COUNT: "count", - // Apple's estimated six-minute-walk-test distance in metres. - SIX_MINUTE_WALK_DISTANCE: "m", - // Stair gait speeds — raw metres-per-second (no scaling). - STAIR_ASCENT_SPEED: "m/s", - STAIR_DESCENT_SPEED: "m/s", - // Per-night breathing-disturbance index — a unitless count Apple - // classifies as NotElevated / Elevated. - BREATHING_DISTURBANCES: "count", - // ── v1.10.0 — categorical events (WX-B) ── - // EVENT rows are dimensionless occurrences (value is always 1). The - // canonical unit is the bare "event" so any accidental numeric surfacing - // reads sensibly; the awareness timeline never displays the value. - IRREGULAR_RHYTHM_NOTIFICATION: "event", - HIGH_HEART_RATE_EVENT: "event", - LOW_HEART_RATE_EVENT: "event", - WALKING_STEADINESS_EVENT: "event", - BREATHING_DISTURBANCE_EVENT: "event", - // ── v1.10.0 — computed scores (WX-C) ── - // Server-derived 0–100 wellness scores. The canonical unit is the bare - // "score" so the value reads sensibly anywhere it surfaces. - RECOVERY_SCORE: "score", - STRESS_SCORE: "score", - STRAIN_SCORE: "score", - // ── v1.11.0 — WHOOP-native score classes ── - // RMSSD HRV is in milliseconds, same canonical unit as the SDNN - // HEART_RATE_VARIABILITY (different estimator, same dimension). - HRV_RMSSD: "ms", - // Day / workout strain ride WHOOP's bounded 0–21 scale; the bare "score" - // unit reads sensibly wherever the value surfaces (distinct from the - // 0–100 COMPUTED STRAIN_SCORE). - DAY_STRAIN: "score", - WORKOUT_STRAIN: "score", - // Sleep quality percentages (0–100). - SLEEP_PERFORMANCE: "%", - SLEEP_EFFICIENCY: "%", - SLEEP_CONSISTENCY: "%", - // Recommended sleep duration in minutes (WHOOP reports ms; mapper ÷60000). - SLEEP_NEED: "minutes", - // Day energy expenditure in kilojoules (WHOOP-native; kept in kJ so the - // device value round-trips rather than being converted to kcal). - ENERGY_EXPENDITURE_KJ: "kJ", - // ── v1.12.8 — WHOOP cycle + sleep coverage completion ── - // Daily average / max heart rate bpm — whole-cycle aggregates, distinct - // from the spot PULSE / RESTING_HEART_RATE / WALKING_HEART_RATE_AVERAGE. - AVERAGE_HEART_RATE: "bpm", - MAX_HEART_RATE: "bpm", - // Per-night sleep disturbance tally — a plain integer count. - SLEEP_DISTURBANCE_COUNT: "count", - // ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ── - ANS_CHARGE: "score", - CARDIO_LOAD: "score", - // ── v1.17.1 — Oura coverage completion ── - // Oura's headline 0–100 Sleep Score — bare "score" like the other 0–100 scores. - SLEEP_SCORE: "score", - // Signed body-temperature deviation in °C (Oura nightly baseline offset). - BODY_TEMPERATURE_DEVIATION: "celsius", - // ── v1.19.0 — Oura resilience ── - // Ordinal level scale (1=limited … 5=exceptional) — the categorical band - // encoded into the numeric value. See RESILIENCE_LEVELS in src/lib/oura/client. - RESILIENCE: "level", - // ── v1.25 — clinical-signals wave ── - PHQ9_SCORE: "score", - GAD7_SCORE: "score", - // ── v1.27.9 — screening scores ── - WHO5_SCORE: "score", - SCI_SCORE: "score", - GRIP_STRENGTH: "kg", - PAIN_NRS: "score", - WAIST_CIRCUMFERENCE: "cm", - WAIST_TO_HEIGHT: "ratio", -}; - -export function getUnitForType(type: string): string { - return unitMap[type] ?? "unknown"; -} +// The canonical unit per type lives in a Zod-free module so the client's +// display transform can read it without pulling Zod into every chart route. +export { getUnitForType } from "@/lib/measurements/unit-map"; // Plausible ranges per measurement type export const VALUE_RANGES: Record = {