diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a6df7858..a5ec3a159 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,85 @@ # Changelog +## [1.43.8] — 2026-10-20 + +### Fixed + +- **Numbers and units read in your language.** Decimals follow your language + everywhere: fever, cycle and mood figures, custom metrics, nutrient totals, + chart tooltips and the sentences the server writes show 37,5 instead of 37.5 + where your language uses a comma. Units such as breaths per minute, years + and minutes read in the app's language instead of the stored English token, + and the cycle and mood comparison boards use the same unit names as the rest + of the app (bpm, not S/min). File sizes read the same in the document vault + and the admin area. +- **Titles wrap instead of being cut.** Tile and card titles wrap onto a second + line with hyphenation instead of ending in an ellipsis, with room between + the lines, and tiles in a grid keep their values on one line when a long + title wraps. Dashboard trend tiles keep their full label on up to two lines. + Long health-score area names, mood tags, document titles, lab names and + token names show in full, and a long field label keeps its colon on the + last word. +- **One look for the same control.** Range, filter and choice toggles (time + ranges, card or table view, recent or all) look and behave the same + everywhere. Small text links and buttons such as "Open schedule", "Select + all" and "View all" are easy to tap on a phone and show a focus ring. + Confidence, score and range chips share one look, and medium confidence has + the same colour on every card. Empty lists and charts say "nothing here yet" + the same way across the app. Page titles on the insights pages, the + medication detail and the check-in screens match every other page title, + and compact cards keep their tight spacing on wide screens. +- The blood-pressure target card labels its systolic bar as well as its + diastolic one, and the sleep-stage legend lists only the stages the visible + nights contain. +- The Dawarich card shows one action row; set your home place from the card + menu. +- **Every published GET response is checked against its OpenAPI schema.** + Thirteen operations that disagreed now publish what they send, the single + mood entry and custom metric reads carry the same fields as their lists, and + the document chat list declares its `fenced`, `attachments` and + `documentTitle` fields. +- **Lists say when they were cut.** Practitioners, vaccinations and encounters + report a cut list and accept `offset`, and the web reads them to the end. + `hasMore` on symptom events is no longer true for a page that is exactly + full. MCP tools say when a list was cut and answer a failure with a stable + code instead of internal error text. +- **A deleted practitioner stays named where it still matters.** Visits and + doses that still show a practitioner removed from the address book keep the + name, in the app, the MCP read and appointment reminders, and the portable + export carries such a practitioner along so the visit keeps its reference + after a restore. +- Renouncing a Guardian role while that Guardian's account is being deleted no + longer answers "add another Guardian". + +### Changed + +- **Deleted records are removed for good after 75 days.** Deleted lab results, + visits, practitioners, allergies, vaccinations, conditions, life events, + cycles and the other soft-deleted records can be restored for 75 days and + are then purged by a nightly job. A deleted record that a live record still + points to is kept for as long as it does. +- Expired sessions, tokens, trusted devices and sign-in challenges are cleaned + up nightly. +- **Writes have one per-account limit** of 300 a minute, edits and deletes + included; bulk sync endpoints and the chat routes keep their own limits. A + temporary database problem with this limit lets the write through instead of + failing every save; the sign-in limits stay strict. +- **Cheaper writes.** Deleting a medication or an account no longer scans whole + tables for related rows, redundant indexes are gone, and writes to + measurements are cheaper. An unused measurement index is dropped at upgrade + when that can happen at once and otherwise by the nightly maintenance, so + upgrading no longer waits on a long-running database read. The unused + per-day medication compliance rollup table is removed; nothing changes in + what you see. +- **Migrations are steadier.** They run in a UTC session regardless of the + database's default time zone, so a migration that mixes `timestamp` and + `timestamptz` no longer writes shifted values on a non-UTC database. A + migration marked safe to re-run that loses a deadlock is retried like one + that timed out on a lock, and the container health check allows up to ten + minutes for a slow migration before reporting the container unhealthy. +- `/api/feature-flags` announces its deprecation and its sunset on 2027-03-31. +- The document vault and the Coach load less JavaScript. + ## [1.43.7] — 2026-10-20 ### Added diff --git a/Dockerfile b/Dockerfile index 625feff6d..6f58628aa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -318,7 +318,9 @@ EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" -HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ +# Start period: the entrypoint migrates before the server listens, and a +# migration waiting out a held lock retries for up to about eight minutes. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10m --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health || exit 1 ENTRYPOINT ["./docker-entrypoint.sh"] diff --git a/bundle-budget.json b/bundle-budget.json index 0f96999b5..bc3ed7437 100644 --- a/bundle-budget.json +++ b/bundle-budget.json @@ -1,14 +1,14 @@ { - "$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. Raised to 4400 at v1.42.0 on a measured delta. The release branch after its tickets merge measured 4365 KB gz (lazy catalogs 1206, other chunks 3159), already 30 KB over the 4335 cap; the day view and timeline contract adds 10 more (catalogs 1215, other 3160), almost all of it the base copy for the day, the timeline and life events in seven locales. The aggregate cap moves from 4335 to 4400, about 25 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4470 at v1.42.0 on a measured delta. The release branch with the day view, the timeline and their server measured 4386 KB gz (lazy catalogs 1228, other chunks 3158); giving every chart drawn in days the same doors to its day (one shared hook for the click, the touch tooltip's button, the open day's line, the row of day dots and the caption, a shared calendar adapter, a data table under each newly linked chart) measured 4446 (catalogs 1228, other chunks 3218). The 60 KB is the aggregate's double count again: the hook, the data table and the day link are emitted into each chart's own chunk group rather than once. The eager routes moved by a kilobyte or a few (/ +1.6, /measurements +1.7, /settings/[section] +0.8, /insights/mood +4.6, /insights +1.5 KB) and stay inside their allowances. The aggregate cap moves from 4400 to 4470, about 24 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4760 at v1.42.0 on a measured delta, the other way round from a feature: the day panel and the date field's calendar left the first load. The day view (its scores, sections and calendar) now loads when a day first opens, not with the shell, and the date field loads react-day-picker and the seven date-fns locales when its popover first opens. Every watched route fell: / 460 to 420, /measurements 439 to 398, /insights/mood 438 to 395, /settings/[section] 432 to 387, /insights 360 to 317 KB gz, all five back under their unchanged ceilings. The aggregate rose from 4426 to 4736 for the reason described at v1.37.27 and v1.40.0: while the day view sat in the shell, the small primitives it shares with the pages (card, table, popover, switch, input, badge, the chart day links) were emitted once into the shared chunk; with the day view on demand, each route group that uses them gets its own copy, and the aggregate counts every copy. Bytes on disk rose; bytes a reader downloads fell on every route. The aggregate cap moves from 4470 to 4760, about 24 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4790 at v1.42.3 on a measured delta. The release branch measured 4762 KB gz (lazy catalogs 1243, other chunks 3519) against the 4760 cap; the 15 KB catalog rise is new copy in the seven lazy locale catalogs (the Withings plan note and its type labels, the Oura reconnect hint, the lab conversion and printed-range notes, the issuer refusal), and the rest is the conversion badge and range note in the scan review's own chunk. The five watched routes stay inside their allowances (/ 422, /measurements 399, /insights/mood 396, /settings/[section] 389, /insights 318 KB gz). The aggregate cap moves from 4760 to 4790, about 28 KB above the build; per-route baselines are unchanged. Raised to 4860 at v1.43.0 on a measured delta. The release branch measured 4834 KB gz (lazy catalogs 1302, other chunks 3532) against the 4790 cap. 29 KB is new copy in the seven lazy locale catalogs (the AI usage levels, the life circumstances and AUDIT-C, the supplement composition, the marker merge, Dawarich, the related-records lists, the pulse day-mean card and the support report), and 13 KB is the on-demand chunks of those surfaces. The five watched routes stay inside their allowances and barely moved since v1.42.3 (/ 423, /measurements 400, /insights/mood 397, /settings/[section] 405, /insights 320 KB gz). The aggregate cap moves from 4790 to 4860, about 26 KB above the build; per-route baselines are unchanged. Re-based at v1.43.0 when Zod left four eager routes. Between the v1.43 budget raise and the merged release branch the four Zod-carrying eager routes grew by about 26 KB gz each (/ 423 to 450, /settings/[section] 405 to 431, /measurements 400 to 426, /insights/mood 397 to 423) while /insights, which loads no Zod, did not move. The whole step was the shared Zod chunk, 63.0 to 88.9 KB gz: zod 4.6 adds an ahead-of-time schema compiler and its memoizer to the core, and because every schema is written against the `z` namespace the bundler keeps the whole namespace (every locale, the JSON Schema converters and now the compiler) wherever Zod loads at all. None of the four routes needed Zod for its first paint. The dashboard and the measurement list read the source list, the notes limit and the screening check out of the two measurement validation modules, the mood page read the Coach source list out of the Coach request schemas, and every settings section rendered a gate that imported the managed settings form and the allergy and family-history forms for a branch only a shared or managed record reaches. The values now live in value-only modules (`@/lib/measurements/sources`, `@/lib/measurements/notes-limit`, `@/lib/measurement-reminders/screening`, `@/lib/ai/coach/scope-sources`) the schemas are built from, and the gate loads its two record-scoped sections on demand. Measured on a local arm64 build: / 450 to 355, /measurements 426 to 335, /insights/mood 423 to 334, /settings/[section] 431 to 307 KB gz; /insights stays at 320. The four route baselines move down to those values, so the gain is held by the same 20 KB allowance rather than spent; a Zod import back on any of them is about 89 KB and fails. The aggregate moves the other way, 4883 to 4949, for the reason described at v1.37.27, v1.40.0 and v1.42.0: the small primitives the removed forms shared with the lazy settings sections and quick-entry forms (popover, switch, the date and time fields) used to be emitted once into the routes' first load, and each on-demand chunk group now carries its own copy, which the aggregate counts every time. Bytes on disk rose; bytes a reader downloads fell by 89 to 124 KB gz on every route that moved. The aggregate cap moves from 4860 to 4975, about 26 KB above the build, the same headroom as before. Raised to 5000 at v1.43.3 on a measured delta. The release branch measured 4977 KB gz (lazy catalogs 1307, other chunks 3670) against the 4975 cap. Every fresh-factor refusal now opens the re-proof dialog, so the hook and the dialog behind it are imported by the managed-profile create and edit forms, the guardian and handover panels, the card, and the advanced and admin key-rotation sections as well, each of them an on-demand chunk the aggregate counts on its own. Loading the dialog on demand through next/dynamic was measured and not kept: 4979 KB gz, and every watched route grew by 0.1 to 0.7 KB for the loader. The five watched routes stay inside their allowances (/ 355, /measurements 335, /insights/mood 334, /settings/[section] 308, /insights 321 KB gz). The aggregate cap moves from 4975 to 5000, about 23 KB above the build; per-route baselines are unchanged. Split at v1.43.7 for the eighth interface language (Dutch). The aggregate counted every lazy message catalog, although a reader downloads only the one for their language, so each new language read as a regression of about 190 KB and the cap had to be raised for copy nobody else loads. The aggregate now covers the client chunks without the lazy catalogs (3670 KB gz measured at v1.43.3, cap 3700, the same headroom as before), and each catalog is held to its own ceiling (about 187 KB gz each at v1.43.3, cap 210). The static-catalog guard, the route baselines and the one-Recharts-chunk guard 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. Raised to 4400 at v1.42.0 on a measured delta. The release branch after its tickets merge measured 4365 KB gz (lazy catalogs 1206, other chunks 3159), already 30 KB over the 4335 cap; the day view and timeline contract adds 10 more (catalogs 1215, other 3160), almost all of it the base copy for the day, the timeline and life events in seven locales. The aggregate cap moves from 4335 to 4400, about 25 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4470 at v1.42.0 on a measured delta. The release branch with the day view, the timeline and their server measured 4386 KB gz (lazy catalogs 1228, other chunks 3158); giving every chart drawn in days the same doors to its day (one shared hook for the click, the touch tooltip's button, the open day's line, the row of day dots and the caption, a shared calendar adapter, a data table under each newly linked chart) measured 4446 (catalogs 1228, other chunks 3218). The 60 KB is the aggregate's double count again: the hook, the data table and the day link are emitted into each chart's own chunk group rather than once. The eager routes moved by a kilobyte or a few (/ +1.6, /measurements +1.7, /settings/[section] +0.8, /insights/mood +4.6, /insights +1.5 KB) and stay inside their allowances. The aggregate cap moves from 4400 to 4470, about 24 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4760 at v1.42.0 on a measured delta, the other way round from a feature: the day panel and the date field's calendar left the first load. The day view (its scores, sections and calendar) now loads when a day first opens, not with the shell, and the date field loads react-day-picker and the seven date-fns locales when its popover first opens. Every watched route fell: / 460 to 420, /measurements 439 to 398, /insights/mood 438 to 395, /settings/[section] 432 to 387, /insights 360 to 317 KB gz, all five back under their unchanged ceilings. The aggregate rose from 4426 to 4736 for the reason described at v1.37.27 and v1.40.0: while the day view sat in the shell, the small primitives it shares with the pages (card, table, popover, switch, input, badge, the chart day links) were emitted once into the shared chunk; with the day view on demand, each route group that uses them gets its own copy, and the aggregate counts every copy. Bytes on disk rose; bytes a reader downloads fell on every route. The aggregate cap moves from 4470 to 4760, about 24 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4790 at v1.42.3 on a measured delta. The release branch measured 4762 KB gz (lazy catalogs 1243, other chunks 3519) against the 4760 cap; the 15 KB catalog rise is new copy in the seven lazy locale catalogs (the Withings plan note and its type labels, the Oura reconnect hint, the lab conversion and printed-range notes, the issuer refusal), and the rest is the conversion badge and range note in the scan review's own chunk. The five watched routes stay inside their allowances (/ 422, /measurements 399, /insights/mood 396, /settings/[section] 389, /insights 318 KB gz). The aggregate cap moves from 4760 to 4790, about 28 KB above the build; per-route baselines are unchanged. Raised to 4860 at v1.43.0 on a measured delta. The release branch measured 4834 KB gz (lazy catalogs 1302, other chunks 3532) against the 4790 cap. 29 KB is new copy in the seven lazy locale catalogs (the AI usage levels, the life circumstances and AUDIT-C, the supplement composition, the marker merge, Dawarich, the related-records lists, the pulse day-mean card and the support report), and 13 KB is the on-demand chunks of those surfaces. The five watched routes stay inside their allowances and barely moved since v1.42.3 (/ 423, /measurements 400, /insights/mood 397, /settings/[section] 405, /insights 320 KB gz). The aggregate cap moves from 4790 to 4860, about 26 KB above the build; per-route baselines are unchanged. Re-based at v1.43.0 when Zod left four eager routes. Between the v1.43 budget raise and the merged release branch the four Zod-carrying eager routes grew by about 26 KB gz each (/ 423 to 450, /settings/[section] 405 to 431, /measurements 400 to 426, /insights/mood 397 to 423) while /insights, which loads no Zod, did not move. The whole step was the shared Zod chunk, 63.0 to 88.9 KB gz: zod 4.6 adds an ahead-of-time schema compiler and its memoizer to the core, and because every schema is written against the `z` namespace the bundler keeps the whole namespace (every locale, the JSON Schema converters and now the compiler) wherever Zod loads at all. None of the four routes needed Zod for its first paint. The dashboard and the measurement list read the source list, the notes limit and the screening check out of the two measurement validation modules, the mood page read the Coach source list out of the Coach request schemas, and every settings section rendered a gate that imported the managed settings form and the allergy and family-history forms for a branch only a shared or managed record reaches. The values now live in value-only modules (`@/lib/measurements/sources`, `@/lib/measurements/notes-limit`, `@/lib/measurement-reminders/screening`, `@/lib/ai/coach/scope-sources`) the schemas are built from, and the gate loads its two record-scoped sections on demand. Measured on a local arm64 build: / 450 to 355, /measurements 426 to 335, /insights/mood 423 to 334, /settings/[section] 431 to 307 KB gz; /insights stays at 320. The four route baselines move down to those values, so the gain is held by the same 20 KB allowance rather than spent; a Zod import back on any of them is about 89 KB and fails. The aggregate moves the other way, 4883 to 4949, for the reason described at v1.37.27, v1.40.0 and v1.42.0: the small primitives the removed forms shared with the lazy settings sections and quick-entry forms (popover, switch, the date and time fields) used to be emitted once into the routes' first load, and each on-demand chunk group now carries its own copy, which the aggregate counts every time. Bytes on disk rose; bytes a reader downloads fell by 89 to 124 KB gz on every route that moved. The aggregate cap moves from 4860 to 4975, about 26 KB above the build, the same headroom as before. Raised to 5000 at v1.43.3 on a measured delta. The release branch measured 4977 KB gz (lazy catalogs 1307, other chunks 3670) against the 4975 cap. Every fresh-factor refusal now opens the re-proof dialog, so the hook and the dialog behind it are imported by the managed-profile create and edit forms, the guardian and handover panels, the card, and the advanced and admin key-rotation sections as well, each of them an on-demand chunk the aggregate counts on its own. Loading the dialog on demand through next/dynamic was measured and not kept: 4979 KB gz, and every watched route grew by 0.1 to 0.7 KB for the loader. The five watched routes stay inside their allowances (/ 355, /measurements 335, /insights/mood 334, /settings/[section] 308, /insights 321 KB gz). The aggregate cap moves from 4975 to 5000, about 23 KB above the build; per-route baselines are unchanged. Split at v1.43.7 for the eighth interface language (Dutch). The aggregate counted every lazy message catalog, although a reader downloads only the one for their language, so each new language read as a regression of about 190 KB and the cap had to be raised for copy nobody else loads. The aggregate now covers the client chunks without the lazy catalogs (3670 KB gz measured at v1.43.3, cap 3700, the same headroom as before), and each catalog is held to its own ceiling (about 187 KB gz each at v1.43.3, cap 210). The static-catalog guard, the route baselines and the one-Recharts-chunk guard are unchanged. Raised to 3755 at v1.43.8 on a measured delta, and the /insights baseline re-based. The release branch measured 3729 KB gz without the lazy catalogs against the 3700 cap (v1.43.7: 3665). Bundling the client entry graph of v1.43.7 and of the release branch once each, deduplicated, measured 3462.6 and 3462.8 KB gz: no dependency, schema or catalog entered the client. The rise is the double count described at v1.37.27 and v1.40.0. The release replaces hand-written controls with shared primitives (the segmented toggle and its roving focus hook, the inline action, the score pill, the page header with its back link, the display-unit resolver), each well under a kilobyte, and the aggregate counts a copy in every chunk group that uses them, up to 88 of them. The /insights baseline still stated the 304 measured when Zod left the page at v1.42.0, although the page measured 320 at v1.43.0 and 321 at v1.43.3, so it had been running on a 4 KB allowance for three releases; it moves to the 323 measured on the runner now, protected by the same 20 KB allowance. The other four watched routes moved by 1.8 to 2.5 KB and keep their baselines. The aggregate cap moves from 3700 to 3755, about 26 KB above the build, the same headroom as before.", "routeBaselineKbGz": { "/page": 355, - "/insights/page": 304, + "/insights/page": 323, "/measurements/page": 335, "/insights/mood/page": 334, "/settings/[section]/page": 307 }, "routeDriftAllowanceKbGz": 20, - "totalClientKbGz": 3700, + "totalClientKbGz": 3755, "maxRechartsChunks": 1, "maxCatalogKbGz": 210 } diff --git a/docker-compose.yml b/docker-compose.yml index 72a75c0c3..fb778bee4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -516,7 +516,13 @@ services: interval: 30s timeout: 5s retries: 3 - start_period: 30s + # The entrypoint applies migrations before the server listens. A + # migration that waits out a held lock retries for up to about eight + # minutes (docs/ops/deploy.md), and failed probes in that time must not + # read as a dead container. The first probe that answers still ends + # the start period, so a normal boot is healthy within 30 seconds of + # listening. + start_period: 10m # Optional dedicated worker container — uncomment + set # HEALTHLOG_PROCESS_TYPE=web on `app` to split the deployment. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 89c03a66b..ae4fc16ec 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.43.7 + version: 1.43.8 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -396,15 +396,17 @@ paths: "409": *a7 "422": *a4 "429": &a12 - description: "Rate limit exceeded. This route shares one per-account bucket with the other single-record writes — + description: "Rate limit exceeded. This route shares one per-account bucket with every other authenticated write — `record-write:`, 300 requests per 60 seconds — keyed on the ACTING account, so a delegate burns - their own allowance rather than the record owner's. Nothing was written. `meta.errorCode` is - `record_write.rate_limited`, which is what tells this refusal apart from a route's own narrower bucket when - both can answer 429. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` - headers describe that bucket; back off on `Retry-After` rather than guessing. Every request counts against - the bucket, refused ones included: the check runs before the body is read, so a client looping on a 422 - spends the same allowance as one that writes rows. A client with more than a handful of rows to send should - use the batch endpoint for its domain instead of looping this one." + their own allowance rather than the record owner's. Only the routes that meter themselves with a dedicated + bucket (the bulk ingest endpoints and the model-turn chats) and a POST that writes nothing stay outside it. + If the bucket cannot be read, the write goes ahead unmetered rather than failing. Nothing was written. + `meta.errorCode` is `record_write.rate_limited`, which is what tells this refusal apart from a route's own + narrower bucket when both can answer 429. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` + and `X-RateLimit-Reset` headers describe that bucket; back off on `Retry-After` rather than guessing. Every + request counts against the bucket, refused ones included: the check runs before the body is read, so a + client looping on a 422 spends the same allowance as one that writes rows. A client with more than a handful + of rows to send should use the batch endpoint for its domain instead of looping this one." headers: *a8 content: application/json: @@ -838,8 +840,9 @@ paths: Deprecated: read `ai` on GET /api/auth/me instead, which resolves each AI capability for the record from the switches, the record's modules, the provider, consent and who is asking. This route projects the operator switches alone and is removed in the first release after the native build that reads `ai` ships. Responses carry - `Deprecation: true` and `Link: ; rel="successor-version"`. The master is applied server-side, so - every sub-switch is already false when it is off. + `Deprecation` (the date it was deprecated, as `@`), `Sunset` (the first day it may stop answering) + and `Link: ; rel="successor-version"`. The master is applied server-side, so every sub-switch is + already false when it is off. An ACTOR surface: it answers about the deployment, not about a record, so it keeps answering while the caller is @@ -854,6 +857,20 @@ paths: responses: "200": description: The resolved assistant matrix. + headers: + Deprecation: + description: "When this route was deprecated, as an RFC 9745 date: `@` and Unix seconds." + schema: + type: string + pattern: ^@[0-9]+$ + Sunset: + description: The first day this route may stop answering (RFC 8594 HTTP-date). + schema: + type: string + Link: + description: The successor, as `; rel="successor-version"`. + schema: + type: string content: application/json: schema: @@ -2969,17 +2986,17 @@ paths: - Auth summary: List recent account-security activity description: "v1.23 — the SHARED security-activity feed: the caller's recent auth + export + deletion audit events with - timestamp, resolved location, and a host-masked IP. `limit` query param caps at 100 (default 50). Reuses the - AuditLog store; no event detail bodies are surfaced." + timestamp, resolved location, and a host-masked IP. `limit` is 1-100 (default 50); a value outside that is a 422 + naming it. Reuses the AuditLog store; no event detail bodies are surfaced." parameters: - in: query name: limit schema: - description: Page size; defaults to 50, clamped to 100. + description: Page size, 1-100; defaults to 50. Out of range is a 422. type: integer minimum: 1 maximum: 100 - description: Page size; defaults to 50, clamped to 100. + description: Page size, 1-100; defaults to 50. Out of range is a 422. responses: "200": description: Recent security events for the caller. @@ -9544,10 +9561,9 @@ paths: defend against a poisoned column. Audit-logged with the old and new zone. - A change is not only a display preference. The medication-compliance rollup keys its rows by the LOCAL day a - dose fell on, minted under whatever zone was in force at write time, so moving the zone re-buckets that history - and the stored keys go stale — and the coverage probe compares counts rather than keys, so it cannot notice. The - route therefore enqueues a re-fold of the trailing window whenever the zone actually changes. Best-effort and + A change is not only a display preference. The supplement nutrient rows key on the LOCAL day a dose fell on, + minted under whatever zone was in force at write time, so moving the zone re-buckets them. The route therefore + enqueues a recompute of the account's supplement nutrients whenever the zone actually changes. Best-effort and de-duplicated, so rapid re-picks collapse into one. requestBody: required: true @@ -15871,8 +15887,9 @@ paths: tags: - Records summary: List visits - description: Returns the caller's live visits, split into `upcoming` and `past`. Bounded and windowed; soft-deleted - visits are excluded. + description: Returns the caller's live visits, split into `upcoming` (soonest first) and `past` (newest first). `limit` + and `offset` apply to each list; `meta.truncated` is true when either list has rows past this page, so a client + reads the next `offset` until it is false. Soft-deleted visits are excluded. parameters: - in: query name: from @@ -16231,7 +16248,8 @@ paths: tags: - Records summary: List practitioners - description: Returns the caller's address book, name-ordered, with an optional case-insensitive name search. + description: Returns the caller's address book, name-ordered, with an optional case-insensitive search over name, + practice and specialty. Paged with `limit` and `offset`; `meta.truncated` says whether another page exists. parameters: - in: query name: q @@ -16245,6 +16263,15 @@ paths: type: integer minimum: 1 maximum: 200 + - in: query + name: offset + schema: + description: Rows to skip, for reading the address book page by page; `meta.truncated` says whether another page exists. + default: 0 + type: integer + minimum: 0 + maximum: 100000 + description: Rows to skip, for reading the address book page by page; `meta.truncated` says whether another page exists. responses: "200": description: The caller's address book. @@ -16343,8 +16370,9 @@ paths: tags: - Records summary: Soft-delete a practitioner - description: Stamps `deletedAt`. Every visit that named this entry survives with no practice attached, because the - reference nulls rather than cascades. Idempotent. Audits as `practitioner.contact.delete`. + description: Stamps `deletedAt`. The entry leaves the address book and the pickers; every live visit and vaccination + that named it keeps showing it, and the tombstone is kept while one does. Idempotent. Audits as + `practitioner.contact.delete`. parameters: - in: path name: id @@ -16506,8 +16534,9 @@ paths: tags: - Records summary: List vaccinations - description: Returns the caller's live doses, newest first, each with its series resolved per component antigen. - Soft-deleted doses are excluded. + description: Returns the caller's live doses, newest first, each with its series resolved per component antigen over the + whole record, never over the page. Paged with `limit` (default 300) and `offset`; `meta.truncated` says whether + another page exists. Soft-deleted doses are excluded. parameters: - in: query name: antigenSlug @@ -16533,6 +16562,15 @@ paths: type: integer minimum: 1 maximum: 500 + - in: query + name: offset + schema: + description: Doses to skip, for reading the log page by page; `meta.truncated` says whether another page exists. + default: 0 + type: integer + minimum: 0 + maximum: 100000 + description: Doses to skip, for reading the log page by page; `meta.truncated` says whether another page exists. responses: "200": description: The caller's immunization history. @@ -36009,6 +36047,13 @@ components: maximum: 9007199254740991 - type: "null" description: v1.7.0 cyclic "off" weeks. Only meaningful when `scheduleType` is CYCLIC; null otherwise. + doseWindows: + description: Per-dose on-time windows as stored. Null when every dose time keeps the default derivation. + anyOf: + - type: array + items: + $ref: "#/components/schemas/DoseWindowEntry" + - type: "null" required: - id - medicationId @@ -36229,24 +36274,24 @@ components: profile_claim — profile_claim.invalid, profile_claim.oidc_only_unsupported, profile_claim.sso_unavailable, profile_claim.taken; record_write — record_write.rate_limited; report-selection — report-selection.body.invalid_json, report-selection.body.invalid_shape, - report-selection.leaves.unknown; route — route.retired; search — search.invalid; share-link — - share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited, - share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited, - share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing — - sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked, - sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope, - sharing.invite.manage_browser_only, sharing.invite.no_account, sharing.invite.refused, - sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, sharing.renounce.not_found, - sharing.revoke.already_ended, sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid, - sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json, - source-priority.body.invalid_shape; symptoms — symptoms.definition.limitReached, - symptoms.definition.notFound, symptoms.episode.notOpen, symptoms.event.notFound, symptoms.invalid; - timeline — timeline.invalid; tokens — tokens.documents.ceiling_reached, - tokens.measurements.ceiling_reached, tokens.workouts.ceiling_reached; vaccination — - vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope, - vaccination.custom-vaccine-not-found, vaccination.custom.invalid, vaccination.custom.name-taken, - vaccination.custom.not-found, vaccination.encounter-not-found, vaccination.identity-required, - vaccination.invalid, vaccination.link.invalid, vaccination.not-found, + report-selection.leaves.unknown; route — route.retired; search — search.invalid; security_activity — + security_activity.invalid; share-link — share-link.document.decryptFailed, share-link.document.notFound, + share-link.document.rateLimited, share-link.documents.invalid, share-link.report.notFound, + share-link.report.rateLimited, share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; + sharing — sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, + sharing.accept.revoked, sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, + sharing.invite.invalid_scope, sharing.invite.manage_browser_only, sharing.invite.no_account, + sharing.invite.refused, sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, + sharing.renounce.not_found, sharing.revoke.already_ended, sharing.revoke.not_found, + sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority — + source-priority.body.invalid_json, source-priority.body.invalid_shape; symptoms — + symptoms.definition.limitReached, symptoms.definition.notFound, symptoms.episode.notOpen, + symptoms.event.notFound, symptoms.invalid; timeline — timeline.invalid; tokens — + tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached, tokens.workouts.ceiling_reached; + vaccination — vaccination.booster-invalid, vaccination.booster-no-antigen, + vaccination.booster-out-of-scope, vaccination.custom-vaccine-not-found, vaccination.custom.invalid, + vaccination.custom.name-taken, vaccination.custom.not-found, vaccination.encounter-not-found, + vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large, workout.batch.source_not_permitted, workout.batch.too_large, workout.delete.synced_source; (unprefixed) — about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file, @@ -46245,6 +46290,13 @@ components: type: - string - "null" + aggregationProvenance: + description: "Authority of an aggregate stored under a `stats:` identity: `HEALTHKIT_STATISTICS`, + `EXPORT_XML_SOURCE_MAX` or `LEGACY_UNKNOWN`. Null on an ordinary sample row. Decode unknown values + leniently." + type: + - string + - "null" glucoseContext: description: Set only on BLOOD_GLUCOSE rows, and null there too when the writer recorded no classification (a sensor export, a device sync). Never invent one. @@ -46684,85 +46736,7 @@ components: - value - measuredAt additionalProperties: false - required: - - WEIGHT - - BLOOD_PRESSURE_SYS - - BLOOD_PRESSURE_DIA - - PULSE - - BODY_FAT - - SLEEP_DURATION - - ACTIVITY_STEPS - - BLOOD_GLUCOSE - - TOTAL_BODY_WATER - - BONE_MASS - - OXYGEN_SATURATION - - HEART_RATE_VARIABILITY - - RESTING_HEART_RATE - - ACTIVE_ENERGY_BURNED - - FLIGHTS_CLIMBED - - WALKING_RUNNING_DISTANCE - - VO2_MAX - - BODY_TEMPERATURE - - FAT_FREE_MASS - - FAT_MASS - - MUSCLE_MASS - - SKIN_TEMPERATURE - - PULSE_WAVE_VELOCITY - - VASCULAR_AGE - - VISCERAL_FAT - - AUDIO_EXPOSURE_ENV - - AUDIO_EXPOSURE_HEADPHONE - - TIME_IN_DAYLIGHT - - WALKING_STEADINESS - - AUDIO_EXPOSURE_EVENT - - RESPIRATORY_RATE - - BODY_MASS_INDEX - - LEAN_BODY_MASS - - WALKING_HEART_RATE_AVERAGE - - WALKING_ASYMMETRY - - WALKING_DOUBLE_SUPPORT - - WALKING_STEP_LENGTH - - WALKING_SPEED - - CARDIO_RECOVERY - - WRIST_TEMPERATURE - - FALL_COUNT - - SIX_MINUTE_WALK_DISTANCE - - STAIR_ASCENT_SPEED - - STAIR_DESCENT_SPEED - - BREATHING_DISTURBANCES - - IRREGULAR_RHYTHM_NOTIFICATION - - HIGH_HEART_RATE_EVENT - - LOW_HEART_RATE_EVENT - - WALKING_STEADINESS_EVENT - - BREATHING_DISTURBANCE_EVENT - - RECOVERY_SCORE - - STRESS_SCORE - - STRAIN_SCORE - - HRV_RMSSD - - DAY_STRAIN - - WORKOUT_STRAIN - - SLEEP_PERFORMANCE - - SLEEP_EFFICIENCY - - SLEEP_CONSISTENCY - - SLEEP_NEED - - ENERGY_EXPENDITURE_KJ - - AVERAGE_HEART_RATE - - MAX_HEART_RATE - - SLEEP_DISTURBANCE_COUNT - - ANS_CHARGE - - CARDIO_LOAD - - SLEEP_SCORE - - BODY_TEMPERATURE_DEVIATION - - RESILIENCE - - PHQ9_SCORE - - GAD7_SCORE - - GRIP_STRENGTH - - PAIN_NRS - - WAIST_CIRCUMFERENCE - - WAIST_TO_HEIGHT - - WHO5_SCORE - - SCI_SCORE - description: Per-type daily series, keyed by MeasurementType. + description: Per-type daily series, keyed by MeasurementType. Only the requested types are present. required: - series additionalProperties: false @@ -47086,6 +47060,13 @@ components: type: - string - "null" + aggregationProvenance: + description: "Authority of an aggregate stored under a `stats:` identity: `HEALTHKIT_STATISTICS`, + `EXPORT_XML_SOURCE_MAX` or `LEGACY_UNKNOWN`. Null on an ordinary sample row. Decode unknown values + leniently." + type: + - string + - "null" glucoseContext: description: Set only on BLOOD_GLUCOSE rows, and null there too when the writer recorded no classification (a sensor export, a device sync). Never invent one. @@ -47284,6 +47265,13 @@ components: type: - string - "null" + aggregationProvenance: + description: "Authority of an aggregate stored under a `stats:` identity: `HEALTHKIT_STATISTICS`, + `EXPORT_XML_SOURCE_MAX` or `LEGACY_UNKNOWN`. Null on an ordinary sample row. Decode unknown values + leniently." + type: + - string + - "null" glucoseContext: description: Set only on BLOOD_GLUCOSE rows, and null there too when the writer recorded no classification (a sensor export, a device sync). Never invent one. @@ -47730,13 +47718,8 @@ components: type: integer minimum: 0 maximum: 9007199254740991 - required: - - IN_BED - - AWAKE - - ASLEEP - - REM - - CORE - - DEEP + description: Minutes per stage. Only the stages the night recorded are present; an absent stage is not zero minutes, it + was not measured. segments: type: array items: @@ -47884,13 +47867,8 @@ components: type: integer minimum: 0 maximum: 9007199254740991 - required: - - IN_BED - - AWAKE - - ASLEEP - - REM - - CORE - - DEEP + description: Minutes per stage. Only the stages the night recorded are present; an absent stage is not zero minutes, it + was not measured. segments: type: array items: @@ -49882,12 +49860,68 @@ components: properties: id: type: string + userId: + description: The record the row belongs to. + type: string name: type: string dose: type: string treatmentClass: $ref: "#/components/schemas/MedicationTreatmentClass" + deliveryForm: + description: How the medication is taken. A one-time injection is `oneShot` with `INJECTION`. + type: string + enum: + - ORAL + - INJECTION + - OTHER + trackInjectionSites: + description: Whether intakes record an injection site. Only offered for `INJECTION`. + type: boolean + allowedInjectionSites: + description: The sites the rotation offers; empty means every site. + type: array + items: + type: string + enum: + - ABDOMEN_LEFT + - ABDOMEN_RIGHT + - ABDOMEN_UPPER_LEFT + - ABDOMEN_UPPER_RIGHT + - THIGH_LEFT + - THIGH_RIGHT + - UPPER_ARM_LEFT + - UPPER_ARM_RIGHT + externalSource: + anyOf: + - type: string + const: APPLE_HEALTH + - type: "null" + description: v1.32.25 — provenance of the medication. `APPLE_HEALTH` marks the row a read-only mirror of the iOS 26+ + HealthKit Medications sync; NULL is a native HealthLog medication. Read-only echo of the stored provenance — + the create route sets it, the list read surfaces it so mirrored-vs-native is visible without inspecting the + iOS client's local registry. + externalId: + description: The source's own identifier for a mirrored medication, paired with `externalSource`; null for a native one. + type: + - string + - "null" + lowStockNotifiedAt: + description: When the low-stock alert last fired for this medication; null when it has not since the stock was last + topped up. Server-written. + 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|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + lowStockNotifiedThresholdDays: + description: The runway threshold, in days, that alert fired at. Server-written. + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" dosesPerUnit: anyOf: - type: integer @@ -50062,15 +50096,11 @@ components: type: boolean description: "v1.40 — whether `POST /api/medications/{id}/courses` may start a new course now: the medication is active, not one-time, has a course and every course has ended." - externalSource: - anyOf: - - type: string - const: APPLE_HEALTH - - type: "null" - description: v1.32.25 — provenance of the medication. `APPLE_HEALTH` marks the row a read-only mirror of the iOS 26+ - HealthKit Medications sync; NULL is a native HealthLog medication. Read-only echo of the stored provenance — - the create route sets it, the list read surfaces it so mirrored-vs-native is visible without inspecting the - iOS client's local registry. + nextDueScheduleId: + description: The schedule `nextDueAt` belongs to; null when nothing is due. + type: + - string + - "null" courseStatus: type: string enum: @@ -50136,6 +50166,7 @@ components: - name - dose - treatmentClass + - externalSource - dosesPerUnit - unitsPerDose - reorderLeadDays @@ -50166,7 +50197,7 @@ components: - courseCount - previousCourseEndedOn - canStartCourse - - externalSource + - nextDueScheduleId - courseStatus - intakeActionable - lastTakenAt @@ -50255,12 +50286,66 @@ components: properties: id: type: string + userId: + description: The record the row belongs to. + type: string name: type: string dose: type: string treatmentClass: $ref: "#/components/schemas/MedicationTreatmentClass" + deliveryForm: + description: How the medication is taken. A one-time injection is `oneShot` with `INJECTION`. + type: string + enum: + - ORAL + - INJECTION + - OTHER + trackInjectionSites: + description: Whether intakes record an injection site. Only offered for `INJECTION`. + type: boolean + allowedInjectionSites: + description: The sites the rotation offers; empty means every site. + type: array + items: + type: string + enum: + - ABDOMEN_LEFT + - ABDOMEN_RIGHT + - ABDOMEN_UPPER_LEFT + - ABDOMEN_UPPER_RIGHT + - THIGH_LEFT + - THIGH_RIGHT + - UPPER_ARM_LEFT + - UPPER_ARM_RIGHT + externalSource: + description: v1.32.25 — provenance of the medication. `APPLE_HEALTH` marks the row a read-only mirror of the iOS 26+ + HealthKit Medications sync; NULL is a native HealthLog medication. + anyOf: + - type: string + const: APPLE_HEALTH + - type: "null" + externalId: + description: The source's own identifier for a mirrored medication, paired with `externalSource`; null for a native one. + type: + - string + - "null" + lowStockNotifiedAt: + description: When the low-stock alert last fired for this medication; null when it has not since the stock was last + topped up. Server-written. + 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|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + lowStockNotifiedThresholdDays: + description: The runway threshold, in days, that alert fired at. Server-written. + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" dosesPerUnit: anyOf: - type: integer @@ -52993,12 +53078,15 @@ components: type: array items: type: string - description: Local calendar days (YYYY-MM-DD) touched so far, driving the chunked rollup recompute behind the write. + description: "Deprecated and inert: always `[]` on a job admitted from v1.43.8, when the per-day compliance rollup it + drove was removed. A job admitted earlier keeps the days it recorded. Kept for clients that decode it; do + not read it." rollupProcessed: type: integer minimum: 0 maximum: 9007199254740991 - description: Of `touchedDays`, how many have had their rollup recomputed. + description: "Deprecated and inert: always `0` on a job admitted from v1.43.8. Kept for clients that decode it; do not + read it." required: - processed - total @@ -55202,12 +55290,26 @@ components: - weekly - biweekly description: "Nudge frequency cap: one per 7 or per 14 days." + nudgeAiComposed: + type: boolean + description: Opt-in for model-written nudge copy. Off by default; the deterministic template is always the fallback. required: - nudgesEnabled - nudgeMedication - nudgeVitals - nudgeRoutine - nudgeFrequency + - nudgeAiComposed + additionalProperties: false + measurementReminder: + type: object + properties: + clientManaged: + type: boolean + description: True when the iOS app owns local check-up reminders; the server-side measurement-reminder APNs send is then + suppressed. Default false. + required: + - clientManaged additionalProperties: false content: type: object @@ -55234,6 +55336,7 @@ components: - mood - cycle - coach + - measurementReminder - content additionalProperties: false PatchNotificationPrefsResponse: @@ -61730,64 +61833,294 @@ components: InsightsComprehensiveResponse: type: object properties: - summary: - type: string - recommendations: - type: array - items: - type: object - propertyNames: - type: string - additionalProperties: {} - citations: - type: array - items: - type: object - propertyNames: - type: string - additionalProperties: {} - warnings: - type: array - items: - type: object - propertyNames: - type: string - additionalProperties: {} - dailyBriefing: - anyOf: - - type: object - propertyNames: - type: string - additionalProperties: {} - - type: "null" - trendAnnotations: - anyOf: - - type: object - propertyNames: - type: string - additionalProperties: {} - - type: "null" - storyboardAnnotations: - type: array - items: - type: object - propertyNames: - type: string - additionalProperties: {} - metricSource: + summaries: type: object propertyNames: type: string - additionalProperties: {} + additionalProperties: + $ref: "#/components/schemas/ComprehensiveMetricSummary" + description: Keyed by `MeasurementType`. Only types with readings in the last 90 days are present. + bmi: + type: + - number + - "null" + bmiClassification: + anyOf: + - type: object + properties: + category: + type: string + color: + type: string + description: A CSS colour token for the web client. + severity: + type: string + enum: + - normal + - elevated + - warning + - danger + required: + - category + - color + - severity + additionalProperties: false + - type: "null" + bpClassification: + anyOf: + - type: object + properties: + category: + type: string + color: + type: string + description: A CSS colour token for the web client. + severity: + type: string + enum: + - normal + - elevated + - warning + - danger + required: + - category + - color + - severity + additionalProperties: false + - type: "null" + bpPctInTarget: + description: Share of blood-pressure readings inside the target band, 0-100. + type: + - number + - "null" + bpTargets: + anyOf: + - type: object + properties: + sysLow: + type: number + sysHigh: + type: number + diaLow: + type: number + diaHigh: + type: number + required: + - sysLow + - sysHigh + - diaLow + - diaHigh + additionalProperties: false + - type: "null" + description: Age-banded targets; null without a date of birth. + weightBpCorrelation: + anyOf: + - $ref: "#/components/schemas/CorrelationStat" + - type: "null" + scatterData: + type: array + items: + type: object + properties: + weight: + type: number + sysBP: + type: number + required: + - weight + - sysBP + additionalProperties: false + description: Weight against systolic pressure, paired by day. + bpMedicationCorrelation: + anyOf: + - type: object + properties: + r: + type: number + strength: + type: string + enum: + - stark + - moderat + - schwach + - keine + description: Strength bucket. The tokens are GERMAN on the wire; treat them as opaque identifiers and localise + client-side. + n: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + medicationCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - r + - strength + - n + - medicationCount + additionalProperties: false + - type: "null" + bpMedicationScatterData: + type: array + items: + type: object + properties: + continuityPct: + type: number + sysBP: + type: number + required: + - continuityPct + - sysBP + additionalProperties: false + moodSummary: + anyOf: + - $ref: "#/components/schemas/ComprehensiveMetricSummary" + - type: "null" + moodBpCorrelation: + anyOf: + - $ref: "#/components/schemas/CorrelationStat" + - type: "null" + moodBpScatterData: + type: array + items: + type: object + properties: + mood: + type: number + sysBP: + type: number + required: + - mood + - sysBP + additionalProperties: false + moodWeightCorrelation: + anyOf: + - $ref: "#/components/schemas/CorrelationStat" + - type: "null" + moodWeightScatterData: + type: array + items: + type: object + properties: + mood: + type: number + weight: + type: number + required: + - mood + - weight + additionalProperties: false + moodPulseCorrelation: + anyOf: + - $ref: "#/components/schemas/CorrelationStat" + - type: "null" + moodPulseScatterData: + type: array + items: + type: object + properties: + mood: + type: number + pulse: + type: number + required: + - mood + - pulse + additionalProperties: false + medications: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + dose: + type: string + category: + type: string + categoryLabel: + description: The label of a custom category; null for a built-in one. + type: + - string + - "null" + compliance7: + type: number + description: Percent, 0-100. + compliance30: + type: number + description: Percent, 0-100. + streak: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + taken7: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + skipped7: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + missed7: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - id + - name + - dose + - category + - categoryLabel + - compliance7 + - compliance30 + - streak + - taken7 + - skipped7 + - missed7 + additionalProperties: false + description: Cadence-aware compliance per active medication. + alerts: + type: array + items: + type: object + properties: + level: + type: string + enum: + - success + - info + - warning + - danger + title: + type: string + message: + type: string + required: + - level + - title + - message + additionalProperties: false + hasProvider: + type: boolean + description: "Whether a configured AI provider exists for the record: provider presence and nothing else. False never + means the operator switched AI off or consent is missing; `ai` says that." + dataSpanDays: + type: integer + minimum: 0 + maximum: 9007199254740991 + totalMeasurements: + type: integer + minimum: 0 + maximum: 9007199254740991 revalidating: + type: boolean description: True when the body is served from last-good cache (stale-while-revalidate) while a fresh aggregation runs in the background. The client keeps polling on `revalidating` (bounded) so the open page converges on the fresh body. - type: boolean - hasProvider: - description: "Whether a configured AI provider exists for the record: provider presence and nothing else. False never - means the operator switched AI off or consent is missing; `ai` says that." - type: boolean ai: type: object properties: @@ -61803,14 +62136,163 @@ components: notes. Resolved per request, never cached with the body. The overview itself is computed data and loads whatever these say." required: - - summary - - recommendations - - citations - - warnings + - summaries + - bmi + - bmiClassification + - bpClassification + - bpPctInTarget + - bpTargets + - weightBpCorrelation + - scatterData + - bpMedicationCorrelation + - bpMedicationScatterData + - moodSummary + - moodBpCorrelation + - moodBpScatterData + - moodWeightCorrelation + - moodWeightScatterData + - moodPulseCorrelation + - moodPulseScatterData + - medications + - alerts + - hasProvider + - dataSpanDays + - totalMeasurements + - revalidating - ai additionalProperties: false description: The Insights overview's computed read. No provider is called on this path; every figure is computed from - the record. + the record. The model-written briefing is not part of it; it is read from `GET /api/insights/generate`. + ComprehensiveMetricSummary: + type: object + properties: + count: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + latest: + type: + - number + - "null" + min: + type: + - number + - "null" + max: + type: + - number + - "null" + mean: + type: + - number + - "null" + median: + type: + - number + - "null" + avg7: + type: + - number + - "null" + avg30: + type: + - number + - "null" + slope7: + anyOf: + - type: object + properties: + slope: + type: number + description: Least-squares slope in units per day. + direction: + type: string + enum: + - up + - down + - stable + confidence: + type: number + description: R² of the fit, 0..1. + required: + - slope + - direction + - confidence + additionalProperties: false + - type: "null" + slope30: + anyOf: + - type: object + properties: + slope: + type: number + description: Least-squares slope in units per day. + direction: + type: string + enum: + - up + - down + - stable + confidence: + type: number + description: R² of the fit, 0..1. + required: + - slope + - direction + - confidence + additionalProperties: false + - type: "null" + anomalyCount: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Readings in the series the outlier rule flags. + avg30LastMonth: + type: + - number + - "null" + avg30LastYear: + type: + - number + - "null" + required: + - count + - latest + - min + - max + - mean + - median + - avg7 + - avg30 + - slope7 + - slope30 + - anomalyCount + additionalProperties: false + CorrelationStat: + type: object + properties: + r: + type: number + strength: + type: string + enum: + - stark + - moderat + - schwach + - keine + description: Strength bucket. The tokens are GERMAN on the wire; treat them as opaque identifiers and localise + client-side. + n: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - r + - strength + - n + additionalProperties: false + description: A significant Pearson correlation (n >= 20, p < 0.05). The field holding it is null when the pairing is not + significant. InsightsCardsEnvelope: type: object properties: @@ -66752,6 +67234,13 @@ components: minimum: -9007199254740991 maximum: 9007199254740991 description: Paired-day count. + suppressed: + description: "Why `result` is null: too few paired days, or a pairing that did not reach significance. Absent when + `result` is set." + type: string + enum: + - insufficientPairs + - notSignificant required: - result - points @@ -71341,14 +71830,11 @@ components: error: type: "null" meta: - type: object - properties: - requestId: - type: string - additionalProperties: false + $ref: "#/components/schemas/ListCapMeta" required: - data - error + - meta additionalProperties: false EncounterList: type: object @@ -72076,14 +72562,11 @@ components: error: type: "null" meta: - type: object - properties: - requestId: - type: string - additionalProperties: false + $ref: "#/components/schemas/ListCapMeta" required: - data - error + - meta additionalProperties: false CreatePractitionerEnvelope: type: object @@ -72305,14 +72788,11 @@ components: error: type: "null" meta: - type: object - properties: - requestId: - type: string - additionalProperties: false + $ref: "#/components/schemas/ListCapMeta" required: - data - error + - meta additionalProperties: false VaccinationList: type: object @@ -75240,7 +75720,7 @@ components: conversations: type: array items: - $ref: "#/components/schemas/DocumentChatConversation" + $ref: "#/components/schemas/DocumentChatListItem" nextCursor: type: - string @@ -75249,7 +75729,7 @@ components: - conversations - nextCursor additionalProperties: false - DocumentChatConversation: + DocumentChatListItem: type: object properties: id: @@ -75262,12 +75742,38 @@ components: type: string messageCount: type: number + fenced: + type: boolean + description: "Always true here: a chat about a document is a fenced thread (no tools, no health snapshot)." + attachments: + description: The documents the thread is about, as they are now. + type: array + items: + type: object + properties: + documentId: + type: string + title: + description: The attached document's title, falling back to its filename; null when it has neither. + type: + - string + - "null" + required: + - documentId + - title + additionalProperties: false + documentTitle: + description: The first attached document's title, for a one-line badge; null when none is attached any more. + type: + - string + - "null" required: - id - title - createdAt - updatedAt - messageCount + - fenced additionalProperties: false DocumentReindexEnvelope: type: object @@ -82886,10 +83392,13 @@ components: timezone: type: string unitPreference: - type: string - enum: - - metric - - imperial + anyOf: + - type: string + enum: + - metric + - imperial + - type: "null" + description: "Null when the record never chose one: metric applies." timeFormat: type: string enum: @@ -84951,8 +85460,8 @@ components: - limit - hasMore additionalProperties: false - description: A page of occurrences, newest first. `hasMore` is true when the page is full; page on with `to` set to the - oldest `occurredAt` seen. + description: A page of occurrences, newest first. `hasMore` is true exactly when an occurrence past this page exists; + page on with `to` set to the oldest `occurredAt` seen. SymptomEvent: type: object properties: @@ -87747,6 +88256,13 @@ components: maximum: 9007199254740991 - type: "null" description: v1.7.0 cyclic "off" weeks. Only meaningful when `scheduleType` is CYCLIC; null otherwise. + doseWindows: + description: Per-dose on-time windows as stored. Null when every dose time keeps the default derivation. + anyOf: + - type: array + items: + $ref: "#/components/schemas/DoseWindowEntryOutput" + - type: "null" required: - id - medicationId @@ -87770,6 +88286,29 @@ components: primitives are mutually exclusive — enforced by the Zod refine on writes, the route layer, and a DB CHECK constraint (`medication_schedules_rrule_xor_rolling`). v1.7.0 adds `scheduleType` (SCHEDULED / PRN / CYCLIC) and the cyclic on/off-week fields. + DoseWindowEntryOutput: + type: object + properties: + timeOfDay: + type: string + pattern: ^([01]\d|2[0-3]):([0-5]\d)$ + description: The dose time this window applies to (matches a `timesOfDay` entry). + start: + type: string + pattern: ^([01]\d|2[0-3]):([0-5]\d)$ + description: On-time band lower bound (HH:mm, user local). + end: + type: string + pattern: ^([01]\d|2[0-3]):([0-5]\d)$ + description: On-time band upper bound (HH:mm, user local). Must be >= `start`. + required: + - timeOfDay + - start + - end + additionalProperties: false + description: One explicit per-dose on-time intake window. `timeOfDay` matches a schedule dose time; `[start, end]` + (HH:mm, user local, `start <= end`) is the on-time band. Outside it the cadence-derived late tail applies, then + ad-hoc. DisableCoachFlagOutput: type: object properties: @@ -87830,12 +88369,66 @@ components: properties: id: type: string + userId: + description: The record the row belongs to. + type: string name: type: string dose: type: string treatmentClass: $ref: "#/components/schemas/MedicationTreatmentClass" + deliveryForm: + description: How the medication is taken. A one-time injection is `oneShot` with `INJECTION`. + type: string + enum: + - ORAL + - INJECTION + - OTHER + trackInjectionSites: + description: Whether intakes record an injection site. Only offered for `INJECTION`. + type: boolean + allowedInjectionSites: + description: The sites the rotation offers; empty means every site. + type: array + items: + type: string + enum: + - ABDOMEN_LEFT + - ABDOMEN_RIGHT + - ABDOMEN_UPPER_LEFT + - ABDOMEN_UPPER_RIGHT + - THIGH_LEFT + - THIGH_RIGHT + - UPPER_ARM_LEFT + - UPPER_ARM_RIGHT + externalSource: + description: v1.32.25 — provenance of the medication. `APPLE_HEALTH` marks the row a read-only mirror of the iOS 26+ + HealthKit Medications sync; NULL is a native HealthLog medication. + anyOf: + - type: string + const: APPLE_HEALTH + - type: "null" + externalId: + description: The source's own identifier for a mirrored medication, paired with `externalSource`; null for a native one. + type: + - string + - "null" + lowStockNotifiedAt: + description: When the low-stock alert last fired for this medication; null when it has not since the stock was last + topped up. Server-written. + 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|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + lowStockNotifiedThresholdDays: + description: The runway threshold, in days, that alert fired at. Server-written. + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" dosesPerUnit: anyOf: - type: integer diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index 89ac3d380..c87d7926b 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -40,6 +40,15 @@ not reachable". The entrypoint also runs the migrations on every boot, so step 2 is optional; it is there for when you want to see the migration result before the new container takes traffic. +The entrypoint runs every migration in a UTC session, the zone the app's own +connections use, whatever the database's default `TimeZone` is: it adds +`-c TimeZone=UTC` to the `options` parameter of `DATABASE_URL` for the Prisma +calls (an `options` value you set yourself is kept, with the pin after it). +Behind a connection pooler that refuses `options`, the +`DATABASE_SESSION_OPTIONS_DISABLED` switch turns this off along with the +app's own session settings; set the database or role default to UTC there +(`ALTER DATABASE healthlog SET timezone = 'UTC'`). + ### A migration that timed out on a lock Some migrations bound how long they wait for a table lock, so that a @@ -59,6 +68,11 @@ Database error code: 55P03 HealthLog: migration 0412_measurement_hot_updates timed out waiting for a lock (55P03) and changed nothing; marking it rolled back and retrying in 15s (retry 1 of 5). ``` +A marked migration that loses a deadlock against an app transaction +(`Database error code: 40P01`) is in the same state, its block rolled back +whole, and is handled the same way; the entrypoint's line then says +`was cancelled in a deadlock (40P01)`. + What happens on its own: such a migration is written so that a timed-out attempt changes nothing, and it says so in its file (`-- @retry-on-lock-timeout`). The entrypoint then marks that one migration @@ -70,6 +84,12 @@ does the same. Nothing else is retried: any other failure, and a lock timeout in a migration that does not carry the marker, stops the boot exactly as before. +The retries fit inside the container's health check: its start period is ten +minutes (`docker-compose.yml` and the image's own `HEALTHCHECK`), so failed +probes while the entrypoint is still migrating do not mark the container +unhealthy. The first probe that answers ends the start period, so a normal +boot is reported healthy within half a minute of the server listening. + If all five retries time out, the log ends with `HealthLog: ERROR — migration … timed out on a lock 6 times in a row; giving up.` and the container exits. Something has held the table for about eight diff --git a/docs/ops/intake-repair.md b/docs/ops/intake-repair.md index c1a16cdc8..3c528d5c3 100644 --- a/docs/ops/intake-repair.md +++ b/docs/ops/intake-repair.md @@ -33,9 +33,9 @@ metadata (`medication_schedules`): - After upgrading to v1.15.19+, when a user reports a compliance rate above 100 %, duplicate entries in the medication history, or a dose shown as taken days away from its slot. -- The compliance rollup counts DISTINCT slots since v1.15.19, so the - percentages self-correct on recompute even without running this script. - Running it additionally clears the phantom rows from the history view. +- Compliance counts DISTINCT slots since v1.15.19, so the percentages are + right even without running this script. Running it additionally clears + the phantom rows from the history view. - The `intake-slot-dedup` worker already collapses canonical-slot duplicates at boot and on a daily cron; this script is the manual, immediately-observable fallback and the only path that surfaces the @@ -104,13 +104,9 @@ healthlog-tsx scripts/repair-intake-anomalies.ts --fix as the duplicate losers). A medication without any derivable anchor (PRN-only / unscheduled) is skipped — there is nothing to compare against. -- **Affected compliance rollups are recomputed** for every touched - `(user, medication, day)`. The script executes the same DISTINCT-slot - aggregation SQL the shared helper runs (a verbatim twin of - `recomputeMedicationComplianceForDay` in - `src/lib/rollups/medication-compliance-rollups.ts` — kept inline so - the script needs no app imports), so the scheduled/taken counts and - the rate self-correct immediately. +- **No separate recompute is needed.** Every compliance reader folds the + rows that share one slot, so the scheduled/taken counts and the rate are + right as soon as the duplicates are tombstoned. The script is idempotent: a second `--fix` run finds zero duplicate groups and changes nothing. Exit code is 0 on success (including a clean diff --git a/docs/ops/measurement-maintenance.md b/docs/ops/measurement-maintenance.md index d6a7865eb..eb95cb053 100644 --- a/docs/ops/measurement-maintenance.md +++ b/docs/ops/measurement-maintenance.md @@ -151,7 +151,8 @@ SELECT pg_size_pretty(pg_total_relation_size('measurements')) AS total, ## The nightly check Since v1.43.7 the same queue also runs by itself at 03:40 (Europe/Berlin). -That run names no steps and changes nothing: vacuuming the table is +That run names no steps and changes nothing beyond dropping a retired index +(next section): vacuuming the table is autovacuum's job, which migration 0412 starts at 5 % dead rows instead of Postgres's 20 %. The check reads the table's live and dead rows and warns (`maintenance_autovacuum_behind` on the wide event) when there are at least @@ -185,6 +186,63 @@ FROM pg_stat_user_tables WHERE relname = 'measurements'; applies to pages written after the migration; a `VACUUM FULL` after the upgrade applies it to the whole table. +## Retired indexes + +An index the schema no longer declares has to leave the database too, and on +`measurements` the obvious migration is the wrong one. A plain `DROP INDEX` +needs an exclusive lock on the table; `DROP INDEX CONCURRENTLY` blocks nobody +but waits, without limit, for every transaction already running on the +table. A migration runs at boot, so either one waits for whatever holds the +table (a `pg_dump`, a long backfill, a `VACUUM FULL`); past the container's +start period the container is replaced with the migration half done, and the +next boot stops at `P3009` until someone runs `prisma migrate resolve`. + +So an index is retired in two halves (v1.43.8, migration 0416, for +`measurements_user_id_type_measured_at_idx`): + +1. The migration tries a plain `DROP INDEX IF EXISTS` inside a `DO` block with + a two-second `lock_timeout` and catches the timeout + (`EXCEPTION WHEN lock_not_available`). When nothing holds the table the + index is gone at once; otherwise the migration still succeeds and the + index stays. The longest the table's other statements can wait behind the + attempt is those two seconds. A new database and Prisma's shadow database + always take the lock, so the migration history ends without the index, + exactly as the schema does, and `prisma migrate dev` / `migrate diff` see + no drift and generate nothing. +2. The index name goes into `RETIRED_MEASUREMENT_INDEXES` in + `src/lib/jobs/measurement-maintenance.ts`. Every run of this job, the + nightly 03:40 check included, drops each name still present with + `DROP INDEX CONCURRENTLY IF EXISTS`, waiting at most 60 seconds for its + locks. A run that runs out of time leaves the index (possibly marked + invalid, which the next drop finishes), sets + `maintenance_retired_index_pending` on the wide event with a warning, and + the next night tries again. The index costs writes until then, as it did + before the release; nothing reads it. + +Until the job has run, the database holds one index the schema does not +declare. Nothing compares the two at runtime, and the only place they are +compared, a `migrate diff` against the migration history, never sees it. To +check by hand: + +```sql +SELECT indexname FROM pg_indexes + WHERE tablename = 'measurements' + AND indexname = 'measurements_user_id_type_measured_at_idx'; +``` + +No row: it is gone. To drop it now rather than at night, start a run from the +admin card (any steps), or run the same statement yourself: + +```sql +SET lock_timeout = '60s'; +DROP INDEX CONCURRENTLY IF EXISTS "measurements_user_id_type_measured_at_idx"; +``` + +For the next large index, follow the same two halves. +`src/__tests__/migration-lock-timeout-guard.test.ts` refuses a +`DROP INDEX CONCURRENTLY` in any migration and requires every retired name to +have a migration that bounds its wait and catches the timeout. + ## "could not resize shared memory segment" Up to v1.42.0 the run could stop at once with `could not resize shared memory diff --git a/docs/self-hosting/reproductive-health-data.md b/docs/self-hosting/reproductive-health-data.md index 557f96633..128dbc6d3 100644 --- a/docs/self-hosting/reproductive-health-data.md +++ b/docs/self-hosting/reproductive-health-data.md @@ -90,8 +90,11 @@ different things depending on which button you press. it out and it is gone from the app, from exports, and from the doctor report. The row itself stays in PostgreSQL as a tombstone, which is what lets a phone that was offline at delete time learn that the entry - went away. No job removes cycle tombstones on a schedule, so the row - stays until one of the two actions below. + went away. A nightly job removes a tombstone 75 days after the delete + (the same horizon as the sync feed's); until then, or until one of the + two actions below, the row stays. A start that was folded into a later + cycle stays for as long as that cycle does, because deleting the later + cycle gives it back. - **Settings → Delete All Data** removes the rows outright, tombstones included. Everything the account owns goes except what you sign in with; the confirmation dialog lists it. diff --git a/e2e/score-history.spec.ts b/e2e/score-history.spec.ts index b91dd5d01..74cd68ddb 100644 --- a/e2e/score-history.spec.ts +++ b/e2e/score-history.spec.ts @@ -137,12 +137,12 @@ async function expectSharedTabs(page: Page, chart: string, pressed: string) { await expect(tabs).toHaveCount(4); expect( await tabs.evaluateAll((nodes) => - nodes.map((node) => node.getAttribute("data-range")), + nodes.map((node) => node.getAttribute("data-value")), ), ).toEqual(RANGES); await expect( page.locator(`${chart} [data-slot="chart-range-tab"][aria-pressed="true"]`), - ).toHaveAttribute("data-range", pressed); + ).toHaveAttribute("data-value", pressed); } test.describe("score histories", () => { @@ -170,7 +170,7 @@ test.describe("score histories", () => { expect(asked).toContain(30); await chart - .locator('[data-slot="chart-range-tab"][data-range="7"]') + .locator('[data-slot="chart-range-tab"][data-value="7"]') .click(); await expect(chart).toHaveAttribute("data-range", "7"); await expect.poll(() => asked.at(-1)).toBe(7); @@ -185,7 +185,7 @@ test.describe("score histories", () => { await expect(chart).toHaveAttribute("data-range", "7"); await chart - .locator('[data-slot="chart-range-tab"][data-range="0"]') + .locator('[data-slot="chart-range-tab"][data-value="0"]') .click(); await expect(chart).toHaveAttribute("data-range", "0"); await expect.poll(() => asked.at(-1)).toBe(3650); @@ -231,7 +231,7 @@ test.describe("score histories", () => { await expect(chart).toHaveAttribute("data-score", "READINESS"); await expectSharedTabs(page, '[data-slot="score-trend-chart"]', "30"); await chart - .locator('[data-slot="chart-range-tab"][data-range="90"]') + .locator('[data-slot="chart-range-tab"][data-value="90"]') .click(); await expect(chart).toHaveAttribute("data-range", "90"); await expect.poll(() => asked.at(-1)).toBe(90); @@ -262,7 +262,7 @@ test.describe("score histories", () => { page.locator(`${chart} [data-slot="chart-overlay-controls-trigger"]`), ).toHaveCount(0); await page - .locator(`${chart} [data-slot="chart-range-tab"][data-range="7"]`) + .locator(`${chart} [data-slot="chart-range-tab"][data-value="7"]`) .click(); await expectSharedTabs(page, chart, "7"); await page.reload(); diff --git a/eslint-plugins/healthlog/__tests__/no-raw-card-shell.test.mjs b/eslint-plugins/healthlog/__tests__/no-raw-card-shell.test.mjs new file mode 100644 index 000000000..37821cfdf --- /dev/null +++ b/eslint-plugins/healthlog/__tests__/no-raw-card-shell.test.mjs @@ -0,0 +1,86 @@ +/** + * Unit tests for the `healthlog/no-raw-card-shell` rule — a card surface is a + * or a shell primitive. Covers the three-part match, the translucent + * and inner-tile radii that stay legal, cn()/template strings, the allow + * option, and the scope gating. + */ + +import { describe, it } from "vitest"; +import { RuleTester } from "eslint"; +import rule from "../no-raw-card-shell.js"; + +RuleTester.describe = describe; +RuleTester.it = it; +RuleTester.itOnly = it.only; + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2023, + sourceType: "module", + parserOptions: { ecmaFeatures: { jsx: true } }, + }, +}); + +const APP_FILE = "/repo/src/components/insights/example.tsx"; + +ruleTester.run("no-raw-card-shell", rule, { + valid: [ + { code: '', filename: APP_FILE }, + // An inner tile: another radius. + { + code: '
', + filename: APP_FILE, + }, + // The translucent hero glass and inner washes are not the card colour. + { + code: '
', + filename: APP_FILE, + }, + // No border: not a shell. + { code: '
', filename: APP_FILE }, + // The primitives under src/components/ui own the shell. + { + code: '
', + filename: "/repo/src/components/ui/trend-tile.tsx", + }, + // An allowed file. + { + code: '
', + filename: "/repo/src/components/charts/health-chart.tsx", + options: [{ allow: ["src/components/charts/health-chart.tsx"] }], + }, + // Outside the UI roots and in tests. + { + code: 'const c = "bg-card border rounded-xl";', + filename: "/repo/src/lib/x.ts", + }, + { + code: '
', + filename: "/repo/src/components/__tests__/x.test.tsx", + }, + ], + invalid: [ + { + code: '
', + filename: APP_FILE, + errors: [{ messageId: "rawShell" }], + }, + { + code: 'cn("bg-card hover:bg-accent/40 block rounded-xl border p-4", className)', + filename: "/repo/src/app/insights/pulse/page.tsx", + errors: [{ messageId: "rawShell" }], + }, + { + code: "const c = `bg-card rounded-xl border-dashed ${x}`;", + filename: APP_FILE, + errors: [{ messageId: "rawShell" }], + }, + // An allow entry for another file does not cover this one. + { + code: '
', + filename: APP_FILE, + options: [{ allow: ["src/components/charts/health-chart.tsx"] }], + errors: [{ messageId: "rawShell" }], + }, + ], +}); diff --git a/eslint-plugins/healthlog/__tests__/no-unlocalised-decimal.test.mjs b/eslint-plugins/healthlog/__tests__/no-unlocalised-decimal.test.mjs new file mode 100644 index 000000000..89b164039 --- /dev/null +++ b/eslint-plugins/healthlog/__tests__/no-unlocalised-decimal.test.mjs @@ -0,0 +1,193 @@ +/** + * Unit tests for the `healthlog/no-unlocalised-decimal` rule — a displayed + * number goes through the app's formatters. Covers the four flagged idioms, + * the rounding round-trip that stays legal, the browser time zone lookup, the + * directive contract, and the scope gating. + */ + +import { describe, expect, it } from "vitest"; +import { Linter, RuleTester } from "eslint"; +import rule from "../no-unlocalised-decimal.js"; + +RuleTester.describe = describe; +RuleTester.it = it; +RuleTester.itOnly = it.only; + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2023, + sourceType: "module", + parserOptions: { ecmaFeatures: { jsx: true } }, + }, +}); + +const APP_FILE = "/repo/src/components/insights/example.tsx"; +const PAGE_FILE = "/repo/src/app/(app)/pulse/page.tsx"; + +ruleTester.run("no-unlocalised-decimal", rule, { + valid: [ + // The formatter is the sanctioned path. + { code: "const s = fmt.number(value, 1);", filename: APP_FILE }, + // Rounding, not display: the result is a number again. + { code: "const n = Number(value.toFixed(2));", filename: APP_FILE }, + { code: "const n = parseFloat(value.toFixed(2));", filename: APP_FILE }, + { + code: "const n = Number.parseFloat(value.toFixed(2));", + filename: APP_FILE, + }, + { code: "const n = +value.toFixed(2);", filename: APP_FILE }, + // A locale is passed. + { code: 'const s = n.toLocaleString("de-DE");', filename: APP_FILE }, + { + code: "const s = d.toLocaleDateString(locale, opts);", + filename: APP_FILE, + }, + { code: "const f = new Intl.NumberFormat(locale);", filename: APP_FILE }, + // The browser time zone lookup formats nothing. + { + code: "const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;", + filename: APP_FILE, + }, + // Integer arithmetic in text is fine. + { code: "const s = `${Math.round(a / b)} h`;", filename: APP_FILE }, + { code: "const s = String(Math.round(a / b));", filename: APP_FILE }, + // A tagged template is not text. + { code: "const q = sql`${a / b}`;", filename: APP_FILE }, + // Wire formats under src/app/api are not display. + { + code: "const s = value.toFixed(2);", + filename: "/repo/src/app/api/export/route.ts", + }, + // Outside the source roots and in tests the rule is silent. + { code: "const s = value.toFixed(2);", filename: "/repo/scripts/x.ts" }, + { + code: "const s = value.toFixed(2);", + filename: "/repo/src/lib/__tests__/x.test.ts", + }, + { + code: "const s = value.toFixed(2);", + filename: "/repo/src/components/__tests__/x.test.tsx", + }, + ], + invalid: [ + { + code: "const s = value.toFixed(1);", + filename: APP_FILE, + errors: [{ messageId: "fixed", data: { method: "toFixed" } }], + }, + // A sentence built on the server for a person: the weekday correlation + // printed "0.8 kg" in a German sentence from src/lib. + { + code: 'const delta = Math.abs(a - b).toFixed(1); t("x", { delta });', + filename: "/repo/src/lib/insights/correlations.ts", + errors: [{ messageId: "fixed" }], + }, + { + code: "const s = value.toFixed(1);", + filename: "/repo/src/hooks/use-x.ts", + errors: [{ messageId: "fixed" }], + }, + { + code: "const s = `${value.toFixed(1)} kg`;", + filename: PAGE_FILE, + errors: [{ messageId: "fixed" }], + }, + { + code: "const s = value.toPrecision(3);", + filename: APP_FILE, + errors: [{ messageId: "fixed", data: { method: "toPrecision" } }], + }, + // Number() around something else does not launder the call. + { + code: "const s = String(Number(a) + value.toFixed(1));", + filename: APP_FILE, + errors: [{ messageId: "fixed" }], + }, + { + code: "{total.toLocaleString()}", + filename: APP_FILE, + errors: [{ messageId: "localeless" }], + }, + { + code: "const s = d.toLocaleDateString(undefined, { day: 'numeric' });", + filename: APP_FILE, + errors: [{ messageId: "localeless" }], + }, + { + code: "const s = d.toLocaleTimeString();", + filename: APP_FILE, + errors: [{ messageId: "localeless" }], + }, + { + code: "const f = new Intl.NumberFormat();", + filename: APP_FILE, + errors: [{ messageId: "intlLocaleless" }], + }, + { + code: "const f = new Intl.DateTimeFormat(undefined, { hour: '2-digit' });", + filename: APP_FILE, + errors: [{ messageId: "intlLocaleless" }], + }, + { + code: "const s = String(Math.round(v * 10) / 10);", + filename: APP_FILE, + errors: [{ messageId: "division" }], + }, + { + code: "const s = `${total / count} per day`;", + filename: APP_FILE, + errors: [{ messageId: "division" }], + }, + ], +}); + +/** + * Directives resolve against the rule's registered name, which RuleTester + * does not use, so they run through a Linter with the plugin registered as + * `healthlog` — the way the flat config loads it. + */ +describe("no-unlocalised-decimal disable directives", () => { + const linter = new Linter({ configType: "flat" }); + const config = [ + { + files: ["**/*.tsx"], + plugins: { healthlog: { rules: { "no-unlocalised-decimal": rule } } }, + rules: { "healthlog/no-unlocalised-decimal": "error" }, + languageOptions: { + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + }, + ]; + const lint = (code) => + linter + .verify(code, config, { filename: "src/components/charts/x.tsx" }) + .map((m) => m.messageId ?? m.message); + + it("flags the site without a directive", () => { + expect(lint("const d = `${x.toFixed(1)}`;")).toEqual(["fixed"]); + }); + + it("accepts a next-line directive that gives a reason", () => { + expect( + lint( + "// eslint-disable-next-line healthlog/no-unlocalised-decimal -- SVG coordinates, not text\nconst d = `${x.toFixed(1)}`;", + ), + ).toEqual([]); + }); + + it("refuses a directive without a reason", () => { + expect( + lint( + "// eslint-disable-next-line healthlog/no-unlocalised-decimal\nconst d = x.toFixed(1);", + ), + ).toEqual(["missingReason"]); + }); + + it("refuses a whole-file disable, which would hide the next site too", () => { + expect( + lint( + "const a = 1;\n/* eslint-disable healthlog/no-unlocalised-decimal -- SVG coordinates, not text */\nconst d = x.toFixed(1);", + ), + ).toEqual(["nextLineOnly"]); + }); +}); diff --git a/eslint-plugins/healthlog/index.js b/eslint-plugins/healthlog/index.js index 7205742f1..ef0b03df6 100644 --- a/eslint-plugins/healthlog/index.js +++ b/eslint-plugins/healthlog/index.js @@ -16,6 +16,11 @@ * slicing an ISO string or parsing UTC midnight. * - `no-planning-tags` — no planning-note identifier at the head of a * comment line. + * - `no-unlocalised-decimal` — a displayed number goes through the app's + * formatters, not toFixed() or a locale-less + * toLocaleString(). + * - `no-raw-card-shell` — a card surface is a or a shell + * primitive, not bg-card + border + rounded-xl. */ "use strict"; @@ -29,6 +34,8 @@ const jobHandlerOutcome = require("./job-handler-outcome.js"); const noDefaultZoneLiteral = require("./no-default-zone-literal.js"); const noUtcDayKey = require("./no-utc-day-key.js"); const noPlanningTags = require("./no-planning-tags.js"); +const noUnlocalisedDecimal = require("./no-unlocalised-decimal.js"); +const noRawCardShell = require("./no-raw-card-shell.js"); module.exports = { rules: { @@ -46,5 +53,7 @@ module.exports = { "no-default-zone-literal": noDefaultZoneLiteral, "no-utc-day-key": noUtcDayKey, "no-planning-tags": noPlanningTags, + "no-unlocalised-decimal": noUnlocalisedDecimal, + "no-raw-card-shell": noRawCardShell, }, }; diff --git a/eslint-plugins/healthlog/no-raw-card-shell.js b/eslint-plugins/healthlog/no-raw-card-shell.js new file mode 100644 index 000000000..5a772c8c8 --- /dev/null +++ b/eslint-plugins/healthlog/no-raw-card-shell.js @@ -0,0 +1,107 @@ +/** + * @fileoverview ESLint rule — a card surface is a `` (or one of the + * shell primitives), not a hand-rolled `bg-card` + `border` + `rounded-xl`. + * + * UI standards §1: "New surfaces compose `` (or `SettingsCard` in + * Settings)". A hand-rolled shell drifts on its own: one had `p-4` with no + * `md:p-6`, one `py-6` with no horizontal inset, one a header row of its own + * instead of `TileHeader`, and every one of them sat beside real cards whose + * padding, gap and shadow it did not share. + * + * FLAGGED, under `src/components/` and `src/app/` outside + * `src/components/ui/` and test files: one string literal whose class list + * carries `bg-card` (the opaque card colour; a translucent `bg-card/NN` is an + * inner tile or the sanctioned hero glass), a `border` utility, and + * `rounded-xl` (the card radius; `rounded-lg` / `rounded-md` are inner tiles + * and controls, §1). + * + * The files where a shell is the point (the shell primitives themselves, the + * hero identity surface, popovers, the sign-in card that renders outside the + * app shell, the chart frame its skeleton and error state share) are passed + * in the `allow` option, each with its reason in the flat config. There is no + * inline escape: a new exception is a reviewed change to that list. + * + * WHAT THIS RULE DOES NOT CATCH + * + * A shell assembled from separate literals (`cn("bg-card", "border", + * "rounded-xl")`), one painted through a CSS class, or one in another radius. + * It holds the idiom the sweep removed, not every way to draw a box. + */ + +"use strict"; + +const ENFORCED_ROOTS = ["src/components/", "src/app/"]; +const EXEMPT_ROOTS = ["src/components/ui/"]; + +const BG_CARD_RE = /(?:^|\s)bg-card(?=\s|$)/; +const BORDER_RE = /(?:^|\s)border(?:-[a-z0-9]|(?=\s|$))/; +const RADIUS_RE = /(?:^|\s)rounded-xl(?=\s|$)/; + +function toPosix(filename) { + return filename.replace(/\\/g, "/"); +} + +function isTestFile(posix) { + return ( + /\.test\.[cm]?[jt]sx?$/.test(posix) || + /\.spec\.[cm]?[jt]sx?$/.test(posix) || + posix.includes("/__tests__/") || + posix.includes("/__mocks__/") + ); +} + +function isShell(value) { + return ( + typeof value === "string" && + BG_CARD_RE.test(value) && + BORDER_RE.test(value) && + RADIUS_RE.test(value) + ); +} + +/** @type {import("eslint").Rule.RuleModule} */ +const noRawCardShellRule = { + meta: { + type: "problem", + docs: { + description: + "Compose (or a shell primitive) instead of a hand-rolled bg-card + border + rounded-xl shell.", + }, + schema: [ + { + type: "object", + properties: { + allow: { type: "array", items: { type: "string" } }, + }, + additionalProperties: false, + }, + ], + messages: { + rawShell: + "Hand-rolled card shell (bg-card + border + rounded-xl). Compose (with TileHeader inside), SettingsCard in Settings, or TrendTile for a trend tile (UI standards §1). A deliberate exception goes into the rule's `allow` list in eslint.config.mjs with its reason.", + }, + }, + create(context) { + const filename = toPosix(context.filename ?? context.getFilename?.() ?? ""); + if (!ENFORCED_ROOTS.some((root) => filename.includes(root))) return {}; + if (EXEMPT_ROOTS.some((root) => filename.includes(root))) return {}; + if (isTestFile(filename)) return {}; + const allow = (context.options[0] && context.options[0].allow) || []; + if (allow.some((suffix) => filename.endsWith(suffix))) return {}; + + function check(node, value) { + if (isShell(value)) context.report({ node, messageId: "rawShell" }); + } + + return { + Literal(node) { + check(node, node.value); + }, + TemplateElement(node) { + check(node, node.value.cooked ?? node.value.raw); + }, + }; + }, +}; + +module.exports = noRawCardShellRule; diff --git a/eslint-plugins/healthlog/no-unlocalised-decimal.js b/eslint-plugins/healthlog/no-unlocalised-decimal.js new file mode 100644 index 000000000..167a18009 --- /dev/null +++ b/eslint-plugins/healthlog/no-unlocalised-decimal.js @@ -0,0 +1,302 @@ +/** + * @fileoverview ESLint rule — a number shown to a person goes through the + * app's locale, never through the JavaScript default. + * + * `value.toFixed(1)` always prints a full stop. A German, French, Italian, + * Spanish, Dutch or Polish reader expects a comma (`37,5 °C`), and the same + * screen already shows a comma wherever the value went through + * `useFormatters()`. The mixed reading is the defect: one card says `70,5 kg`, + * the card beside it `70.5 kg`. `toLocaleString()` without a locale is the + * mirror image of the same mistake — it follows the browser, not the language + * the person picked in HealthLog. + * + * The legitimate forms: + * + * - in a component: `const fmt = useFormatters(); fmt.number(value, 1)` + * (`fmt.integer`, `fmt.percent` for the other shapes) + * - outside React: `makeFormatters(locale, …)` from `@/lib/format-locale` + * - rounding, not display: `Number(value.toFixed(2))` stays legal — the + * result is a number again and is formatted wherever it is shown + * + * FLAGGED, in `src/components/`, `src/app/`, `src/hooks/` and `src/lib/` + * outside `src/app/api/` (wire formats are not display) and outside test + * files. `src/lib/` is in scope because sentences for people are built + * there too (a correlation hypothesis, a deterministic fallback, a narrative) + * and the weekday correlation printed "0.8 kg" into a German sentence; a + * machine-read string there (a prompt for the model, an operator log line, + * SVG path data, a wire value a client re-formats) carries a reasoned + * directive like any other. + * + * - `.toFixed(…)` and `.toPrecision(…)`, unless the call is the + * argument of `Number(…)` / `parseFloat(…)` / `Number.parseFloat(…)` or + * the operand of a unary `+` + * - `.toLocaleString()`, `toLocaleDateString()`, `toLocaleTimeString()` + * called with no locale or with `undefined` as the locale + * - `Intl.NumberFormat()` / `Intl.DateTimeFormat()` constructed with no + * locale or with `undefined` (reading `.resolvedOptions()` off it, the + * browser time zone lookup, formats nothing and passes) + * - a division rendered straight into text: `String(a / b)` and + * `` `${a / b}` `` — the quotient is fractional and prints with a stop + * + * A site where the stop is the point (SVG path data, a value written into a + * machine-read attribute) stays, with the reason on the line above: + * + * // eslint-disable-next-line healthlog/no-unlocalised-decimal -- + * + * The rule refuses a directive for itself without a reason, or in any form + * other than `eslint-disable-next-line`. + * + * WHAT THIS RULE DOES NOT CATCH + * + * Without type information it cannot tell a number from a string, so + * `String(weight)` or `` `${weight} kg` `` with a fractional `weight` passes, + * as does a quotient assigned to a variable first and rendered later. It + * removes the idioms that print a stop by construction, not every path to + * one. + */ + +"use strict"; + +const RULE_NAME = "healthlog/no-unlocalised-decimal"; +const ENFORCED_ROOTS = [ + "src/components/", + "src/app/", + "src/hooks/", + "src/lib/", +]; +const EXEMPT_ROOTS = ["src/app/api/"]; +const MIN_REASON_CHARS = 8; + +const FIXED_METHODS = new Set(["toFixed", "toPrecision"]); +const LOCALE_METHODS = new Set([ + "toLocaleString", + "toLocaleDateString", + "toLocaleTimeString", +]); +const INTL_CTORS = new Set(["NumberFormat", "DateTimeFormat"]); + +function toPosix(filename) { + return filename.replace(/\\/g, "/"); +} + +function isTestFile(posix) { + return ( + /\.test\.[cm]?[jt]sx?$/.test(posix) || + /\.spec\.[cm]?[jt]sx?$/.test(posix) || + posix.includes("/__tests__/") || + posix.includes("/__mocks__/") + ); +} + +function isEnforced(filename) { + const posix = toPosix(filename); + if (!ENFORCED_ROOTS.some((root) => posix.includes(root))) return false; + if (EXEMPT_ROOTS.some((root) => posix.includes(root))) return false; + if (isTestFile(posix)) return false; + return true; +} + +function propertyName(member) { + if (!member || member.type !== "MemberExpression") return null; + if (!member.computed && member.property.type === "Identifier") { + return member.property.name; + } + if (member.computed && member.property.type === "Literal") { + return String(member.property.value); + } + return null; +} + +function isUndefined(node) { + return ( + !node || + (node.type === "Identifier" && node.name === "undefined") || + (node.type === "UnaryExpression" && node.operator === "void") + ); +} + +/** Strip TS-only wrappers and parentheses-equivalents the parser keeps. */ +function unwrap(node) { + let current = node; + while ( + current && + (current.type === "TSAsExpression" || + current.type === "TSNonNullExpression" || + current.type === "TSSatisfiesExpression" || + current.type === "ChainExpression") + ) { + current = current.expression; + } + return current; +} + +/** The call's value is turned back into a number: rounding, not display. */ +function isNumericRoundTrip(callNode) { + let child = callNode; + let parent = callNode.parent; + while ( + parent && + (parent.type === "TSAsExpression" || + parent.type === "TSNonNullExpression" || + parent.type === "ChainExpression") + ) { + child = parent; + parent = parent.parent; + } + if (!parent) return false; + if (parent.type === "UnaryExpression" && parent.operator === "+") { + return true; + } + if (parent.type !== "CallExpression" || parent.arguments[0] !== child) { + return false; + } + const callee = parent.callee; + if (callee.type === "Identifier") { + return callee.name === "Number" || callee.name === "parseFloat"; + } + return ( + callee.type === "MemberExpression" && + callee.object.type === "Identifier" && + callee.object.name === "Number" && + propertyName(callee) === "parseFloat" + ); +} + +function isDivision(node) { + const inner = unwrap(node); + return !!inner && inner.type === "BinaryExpression" && inner.operator === "/"; +} + +function isIntlCtor(callee) { + return ( + callee && + callee.type === "MemberExpression" && + callee.object.type === "Identifier" && + callee.object.name === "Intl" && + INTL_CTORS.has(propertyName(callee)) + ); +} + +/** @type {import("eslint").Rule.RuleModule} */ +const noUnlocalisedDecimalRule = { + meta: { + type: "problem", + docs: { + description: + "Format a displayed number through useFormatters() / makeFormatters(), not toFixed() or a locale-less toLocaleString().", + }, + schema: [], + messages: { + fixed: + "`{{method}}()` prints a full stop in every language. Format the value with `useFormatters().number(value, digits)` (or `makeFormatters` outside React). For rounding, wrap it in `Number(…)`. If the stop is meant, say why: `// eslint-disable-next-line healthlog/no-unlocalised-decimal -- `.", + localeless: + "`{{method}}()` without a locale follows the browser, not the language picked in HealthLog. Use `useFormatters()` or pass the active locale.", + intlLocaleless: + "`Intl.{{ctor}}` without a locale follows the browser, not the language picked in HealthLog. Use `useFormatters()` or pass the active locale.", + division: + "A quotient rendered straight into text prints with a full stop. Format it with `useFormatters().number(value, digits)`.", + missingReason: + "A disable directive for healthlog/no-unlocalised-decimal must say why the full stop is right here: `-- ` after the rule name.", + nextLineOnly: + "Disable healthlog/no-unlocalised-decimal only with `eslint-disable-next-line`, one site at a time, so the next occurrence is still caught.", + }, + }, + create(context) { + const filename = context.filename ?? context.getFilename?.(); + if (!filename || !isEnforced(filename)) { + return {}; + } + const sourceCode = context.sourceCode ?? context.getSourceCode(); + + function checkDirectives() { + for (const comment of sourceCode.getAllComments()) { + const text = comment.value.trim(); + const match = /^eslint-disable(-next-line|-line)?\b([\s\S]*)$/.exec( + text, + ); + if (!match || !match[2].includes(RULE_NAME)) continue; + if (match[1] !== "-next-line") { + context.report({ + loc: { line: 1, column: 0 }, + messageId: "nextLineOnly", + }); + continue; + } + const reason = match[2].split(/\s--\s/)[1]; + if (!reason || reason.trim().length < MIN_REASON_CHARS) { + context.report({ loc: comment.loc, messageId: "missingReason" }); + } + } + } + + function checkIntl(node) { + // `Intl.DateTimeFormat().resolvedOptions().timeZone` reads the + // browser's zone; nothing is formatted, so the missing locale is right. + const readsOptionsOnly = + node.parent && + node.parent.type === "MemberExpression" && + node.parent.object === node && + propertyName(node.parent) === "resolvedOptions"; + if ( + !readsOptionsOnly && + isIntlCtor(node.callee) && + isUndefined(node.arguments[0]) + ) { + context.report({ + node, + messageId: "intlLocaleless", + data: { ctor: propertyName(node.callee) }, + }); + } + } + + return { + "Program:exit": checkDirectives, + CallExpression(node) { + const name = propertyName(node.callee); + if (name && FIXED_METHODS.has(name)) { + if (!isNumericRoundTrip(node)) { + context.report({ + node, + messageId: "fixed", + data: { method: name }, + }); + } + return; + } + if (name && LOCALE_METHODS.has(name)) { + if (isUndefined(node.arguments[0])) { + context.report({ + node, + messageId: "localeless", + data: { method: name }, + }); + } + return; + } + if ( + node.callee.type === "Identifier" && + node.callee.name === "String" && + isDivision(node.arguments[0]) + ) { + context.report({ node, messageId: "division" }); + return; + } + checkIntl(node); + }, + NewExpression: checkIntl, + TemplateLiteral(node) { + if (node.parent && node.parent.type === "TaggedTemplateExpression") { + return; + } + for (const expression of node.expressions) { + if (isDivision(expression)) { + context.report({ node: expression, messageId: "division" }); + } + } + }, + }; + }, +}; + +module.exports = noUnlocalisedDecimalRule; diff --git a/eslint.config.mjs b/eslint.config.mjs index 3f53b7b7d..a6bf8aac0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -121,6 +121,61 @@ const eslintConfig = defineConfig([ // scripts/strip-planning-tags.mjs; see eslint-plugins/healthlog/ // planning-tags.js for the shape and its limits. "healthlog/no-planning-tags": "error", + // A number shown to a person goes through useFormatters() (or + // makeFormatters / formatDecimal outside React), in src/components, + // src/app, src/hooks and src/lib: toFixed() prints a full stop in every + // language and a locale-less toLocaleString() follows the browser + // instead of the language picked in the app. SVG path data and other + // machine-read strings carry `eslint-disable-next-line ... -- `; + // the rule refuses a directive without one. See the rule header. + "healthlog/no-unlocalised-decimal": "error", + // A card surface is a (or SettingsCard / TrendTile), never a + // hand-rolled bg-card + border + rounded-xl (UI standards §1). The + // files below are where a shell is the point; each says why. A new + // exception is a reviewed change to this list, there is no inline + // escape. See the rule header. + "healthlog/no-raw-card-shell": [ + "error", + { + allow: [ + // The shell primitives themselves. + "src/components/settings/settings-card.tsx", + "src/components/medications/medication-detail-section.tsx", + // The sanctioned identity surface of the dashboard (§1). + "src/components/daily/today-hero.tsx", + "src/components/daily/today-hero-skeleton.tsx", + // Floating surfaces: a chart tooltip, the tour popovers, the + // wizard's icon tile. + "src/components/charts/chart-tooltip.tsx", + "src/components/onboarding/tour.tsx", + "src/components/medications/wizard/medication-wizard-dialog.tsx", + // The chart frame: chart, skeleton and error state paint the + // same frame so a chart never moves when it loads or fails. + "src/components/charts/health-chart.tsx", + "src/components/charts/chart-skeleton.tsx", + "src/components/charts/chart-error-state.tsx", + "src/components/charts/medication-compliance-chart.tsx", + "src/components/insights/derived/score-trend-chart.tsx", + "src/components/insights/intraday-pulse-chart.tsx", + // Table frames: a clipped, unpadded border around a table. + "src/components/medications/medication-table.tsx", + "src/app/notifications/page.tsx", + // Surfaces that render outside the app shell: sign-in, register, + // claim, the key-mismatch gate. + "src/app/auth/login/page.tsx", + "src/app/auth/register/page.tsx", + "src/components/auth/claim-profile.tsx", + "src/app/key-mismatch-page.tsx", + // The first-run flow: full-screen steps with their own rhythm. + "src/components/onboarding/anamnesis-card.tsx", + "src/components/onboarding/baseline-fields.tsx", + "src/components/onboarding/confirm-screen.tsx", + "src/components/onboarding/done-screen.tsx", + "src/components/onboarding/first-result-screen.tsx", + "src/components/onboarding/getting-started-checklist.tsx", + ], + }, + ], }, }, // v1.28.17 — every recharts-rendering component funnels through the diff --git a/messages/de.json b/messages/de.json index 9bab26a04..030c254cc 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2597,7 +2597,8 @@ "complianceHeatmap": "Heatmap zur Medikamenteneinnahme von {start} bis {end}, {days} aktive Tage.", "complianceHeatmapEmpty": "Heatmap zur Medikamenteneinnahme, noch keine aktiven Tage." }, - "medicationsEmptyAction": "Medikament anlegen" + "medicationsEmptyAction": "Medikament anlegen", + "rangeLabel": "Zeitraum" }, "chart": { "overlay": { @@ -4220,13 +4221,8 @@ "metricNextDayRecovery": "Erholung am Folgetag", "metricNextDayRestingHr": "Ruhepuls am Folgetag", "metricNextDayHrv": "Herzfrequenzvariabilität am Folgetag", - "unitHours": "Std.", - "unitKcal": "kcal", - "unitScore": "Pkt.", - "unitBpm": "bpm", - "unitMs": "ms", - "detailSameDay": "Durchschnittlich {withAvg} {unit} an den {withDays} Tagen mit diesem Tag gegenüber {withoutAvg} {unit} ohne.", - "detailNextDay": "Durchschnittlich {withAvg} {unit} am Tag nach den {withDays} Tagen mit diesem Tag gegenüber {withoutAvg} {unit} sonst." + "detailSameDay": "Durchschnittlich {withAvg} an den {withDays} Tagen mit diesem Tag gegenüber {withoutAvg} ohne.", + "detailNextDay": "Durchschnittlich {withAvg} am Tag nach den {withDays} Tagen mit diesem Tag gegenüber {withoutAvg} sonst." }, "factorCrosstab": { "title": "Deine bewerteten Faktoren und deine Vitalwerte", @@ -4238,15 +4234,8 @@ "metricHrv": "Herzfrequenzvariabilität", "metricWeight": "Gewicht", "metricBloodPressureSys": "Systolisch", - "unitHours": "Std.", - "unitScore": "Pkt.", - "unitSteps": "Schritte", - "unitBpm": "bpm", - "unitMs": "ms", - "unitKg": "kg", - "unitMmHg": "mmHg", - "detail": "Durchschnittlich {lowAvg} {unit} an den {lowDays} Tagen mit niedriger {factor}-Bewertung gegenüber {highAvg} {unit} an den Tagen mit hoher Bewertung.", - "detailInverse": "Durchschnittlich {lowAvg} {unit} an deinen {lowDays} schlechteren {factor}-Tagen gegenüber {highAvg} {unit} an deinen besseren." + "detail": "Durchschnittlich {lowAvg} an den {lowDays} Tagen mit niedriger {factor}-Bewertung gegenüber {highAvg} an den Tagen mit hoher Bewertung.", + "detailInverse": "Durchschnittlich {lowAvg} an deinen {lowDays} schlechteren {factor}-Tagen gegenüber {highAvg} an deinen besseren." }, "discovery": { "title": "Was auffällt", @@ -6673,21 +6662,12 @@ } }, "integrationPill": { - "connected": "Verbunden", - "errorReconnect": "Fehler: neu verbinden", - "warningServerError": "Sync fehlgeschlagen", - "failureCount": "{count}/{threshold}", - "parkedReconnect": "Pausiert: neu verbinden", - "notConnected": "Nicht verbunden", "justNow": "gerade eben", "minutesAgo": "vor {count} min", "hoursAgo": "vor {count} Std.", "daysAgo": "vor {count} d", "resumeSuccess": "Integration wieder aktiv", - "resumeError": "Wieder verbinden fehlgeschlagen", - "stale": "Verbunden, keine neuen Daten", - "stalled": "Sync gestoppt", - "pendingSetup": "Warte auf erste Daten" + "resumeError": "Wieder verbinden fehlgeschlagen" }, "integrationFreshness": { "title": "Empfangene Daten", @@ -7756,6 +7736,7 @@ "tokensEmptyTitle": "Noch keine API-Tokens", "tokensEmptyDescription": "Tokens werden bei der Anmeldung nativer Clients oder über Einstellungen → Account ausgestellt.", "loginOverviewDescription": "Anmelde- und Auth-Ereignisse aller Konten, filterbar und als CSV exportierbar.", + "loginOverviewFilterLabel": "Ereignisse filtern", "allAuthEvents": "Alle Auth-Ereignisse", "failedOnly": "Nur fehlgeschlagene", "loginEmptyDescription": "Anmeldungen, Abmeldungen und Passkey-Änderungen erscheinen hier.", @@ -7884,6 +7865,7 @@ "admin": "Admins", "user": "Nutzer" }, + "filterLabel": "Benutzer filtern", "forceLogout": "Abmeldung erzwingen", "forceLogoutConfirmTitle": "{name} abmelden?", "forceLogoutConfirmBody": "Alle aktiven Sitzungen dieses Nutzers werden widerrufen. Auf allen Geräten ist eine erneute Anmeldung erforderlich.", @@ -8909,6 +8891,7 @@ "inTarget": "Im Zielbereich", "aboveTarget": "{delta} {unit} über Zielbereich", "belowTarget": "{delta} {unit} unter Zielbereich", + "systolic": "Systolisch", "diastolic": "Diastolisch", "glucoseFasting": "Glukose, nüchtern", "glucosePostprandial": "Glukose, postprandial", @@ -9165,9 +9148,9 @@ "insights": { "headline": { "title": "Dein Zyklus und dein Körper", - "restingHeartRate": "Dein Ruhepuls liegt in der Lutealphase etwa {delta} {unit} {dir} als in der Follikelphase.", - "heartRateVariability": "Deine Herzfrequenzvariabilität liegt in der Lutealphase etwa {delta} {unit} {dir} als in der Follikelphase.", - "generic": "Dein/e {metric} liegt in der Lutealphase etwa {delta} {unit} {dir} als in der Follikelphase.", + "restingHeartRate": "Dein Ruhepuls liegt in der Lutealphase etwa {delta} {dir} als in der Follikelphase.", + "heartRateVariability": "Deine Herzfrequenzvariabilität liegt in der Lutealphase etwa {delta} {dir} als in der Follikelphase.", + "generic": "Dein/e {metric} liegt in der Lutealphase etwa {delta} {dir} als in der Follikelphase.", "dirHigher": "höher", "dirLower": "niedriger", "empty": "Noch nicht genug Zyklen und Messwerte für ein Phasenmuster. Logge weiter, das füllt sich, je länger deine Historie wird." @@ -9175,7 +9158,7 @@ "crosstab": { "description": "Wie sich jeder Messwert zwischen deiner Luteal- und Follikelphase vergleicht, auf deinen eigenen Daten.", "pairLabel": "{metric}: Luteal- vs. Follikelphase", - "detail": "Luteal {lutealAvg} {unit} ({lutealDays} Tage) vs. Follikel {follicularAvg} {unit} ({follicularDays} Tage).", + "detail": "Luteal {lutealAvg} ({lutealDays} Tage) vs. Follikel {follicularAvg} ({follicularDays} Tage).", "metricRestingHeartRate": "Ruhepuls", "metricHrv": "Herzfrequenzvariabilität", "metricSleepDuration": "Schlafdauer", @@ -9185,15 +9168,7 @@ "metricWristTemperature": "Handgelenkstemperatur nachts", "metricSkinTemperature": "Hauttemperatur", "metricBloodGlucose": "Blutzucker", - "metricMood": "Stimmung", - "unitHours": "Std.", - "unitSteps": "Schritte", - "unitBpm": "S/min", - "unitMs": "ms", - "unitMood": "Pkt.", - "confidenceLow": "Geringe Sicherheit", - "confidenceMedium": "Mittlere Sicherheit", - "confidenceHigh": "Hohe Sicherheit" + "metricMood": "Stimmung" }, "title": "Zyklus-Erkenntnisse", "symptomPatternsTitle": "Symptommuster nach Phase", diff --git a/messages/en.json b/messages/en.json index eb2056a98..a486f636a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2597,7 +2597,8 @@ "complianceHeatmap": "Medication compliance heatmap from {start} to {end}, {days} active days.", "complianceHeatmapEmpty": "Medication compliance heatmap, no active days yet." }, - "medicationsEmptyAction": "Add a medication" + "medicationsEmptyAction": "Add a medication", + "rangeLabel": "Time range" }, "chart": { "overlay": { @@ -4220,13 +4221,8 @@ "metricNextDayRecovery": "Next-day recovery", "metricNextDayRestingHr": "Next-day resting heart rate", "metricNextDayHrv": "Next-day heart-rate variability", - "unitHours": "h", - "unitKcal": "kcal", - "unitScore": "pts", - "unitBpm": "bpm", - "unitMs": "ms", - "detailSameDay": "Averages {withAvg} {unit} on the {withDays} days you tagged this vs {withoutAvg} {unit} without it.", - "detailNextDay": "Averages {withAvg} {unit} the day after the {withDays} days you tagged this vs {withoutAvg} {unit} otherwise." + "detailSameDay": "Averages {withAvg} on the {withDays} days you tagged this vs {withoutAvg} without it.", + "detailNextDay": "Averages {withAvg} the day after the {withDays} days you tagged this vs {withoutAvg} otherwise." }, "factorCrosstab": { "title": "Your rated factors and your vitals", @@ -4238,15 +4234,8 @@ "metricHrv": "Heart-rate variability", "metricWeight": "Weight", "metricBloodPressureSys": "Systolic", - "unitHours": "h", - "unitScore": "pts", - "unitSteps": "steps", - "unitBpm": "bpm", - "unitMs": "ms", - "unitKg": "kg", - "unitMmHg": "mmHg", - "detail": "Averages {lowAvg} {unit} on the {lowDays} days you rated {factor} low vs {highAvg} {unit} on the days you rated it high.", - "detailInverse": "Averages {lowAvg} {unit} on your {lowDays} worse {factor} days vs {highAvg} {unit} on your better ones." + "detail": "Averages {lowAvg} on the {lowDays} days you rated {factor} low vs {highAvg} on the days you rated it high.", + "detailInverse": "Averages {lowAvg} on your {lowDays} worse {factor} days vs {highAvg} on your better ones." }, "discovery": { "title": "What stands out", @@ -6673,21 +6662,12 @@ } }, "integrationPill": { - "connected": "Connected", - "errorReconnect": "Error: reconnect", - "warningServerError": "Sync failing", - "failureCount": "{count}/{threshold}", - "parkedReconnect": "Paused: reconnect", - "notConnected": "Not connected", "justNow": "just now", "minutesAgo": "{count} min ago", "hoursAgo": "{count} h ago", "daysAgo": "{count} d ago", "resumeSuccess": "Integration resumed", - "resumeError": "Reconnect failed", - "stale": "Connected, no recent data", - "stalled": "Sync stopped", - "pendingSetup": "Waiting for first data" + "resumeError": "Reconnect failed" }, "integrationFreshness": { "title": "Data received", @@ -7756,6 +7736,7 @@ "tokensEmptyTitle": "No API tokens yet", "tokensEmptyDescription": "Tokens are issued when a native client signs in or via Settings → Account.", "loginOverviewDescription": "Sign-in and auth events across all accounts, filterable and exportable as CSV.", + "loginOverviewFilterLabel": "Filter events", "allAuthEvents": "All auth events", "failedOnly": "Failed only", "loginEmptyDescription": "Sign-ins, sign-outs, and passkey changes will show up here.", @@ -7884,6 +7865,7 @@ "admin": "Admins", "user": "Users" }, + "filterLabel": "Filter users", "forceLogout": "Force logout", "forceLogoutConfirmTitle": "Force logout {name}?", "forceLogoutConfirmBody": "All active sessions for this user will be revoked. They will need to sign in again on every device.", @@ -8909,6 +8891,7 @@ "inTarget": "Within target range", "aboveTarget": "{delta} {unit} above target", "belowTarget": "{delta} {unit} below target", + "systolic": "Systolic", "diastolic": "Diastolic", "glucoseFasting": "Glucose (fasting)", "glucosePostprandial": "Glucose (post-meal)", @@ -9165,9 +9148,9 @@ "insights": { "headline": { "title": "Your cycle and your body", - "restingHeartRate": "Your resting heart rate runs about {delta} {unit} {dir} in your luteal phase than your follicular phase.", - "heartRateVariability": "Your heart-rate variability runs about {delta} {unit} {dir} in your luteal phase than your follicular phase.", - "generic": "Your {metric} runs about {delta} {unit} {dir} in your luteal phase than your follicular phase.", + "restingHeartRate": "Your resting heart rate runs about {delta} {dir} in your luteal phase than your follicular phase.", + "heartRateVariability": "Your heart-rate variability runs about {delta} {dir} in your luteal phase than your follicular phase.", + "generic": "Your {metric} runs about {delta} {dir} in your luteal phase than your follicular phase.", "dirHigher": "higher", "dirLower": "lower", "empty": "Not enough cycles and readings yet to surface a phase pattern. Keep logging; this fills in as your history grows." @@ -9175,7 +9158,7 @@ "crosstab": { "description": "How each measurement compares between your luteal and follicular phases, on your own data.", "pairLabel": "{metric}: luteal vs follicular", - "detail": "Luteal {lutealAvg} {unit} ({lutealDays} days) vs follicular {follicularAvg} {unit} ({follicularDays} days).", + "detail": "Luteal {lutealAvg} ({lutealDays} days) vs follicular {follicularAvg} ({follicularDays} days).", "metricRestingHeartRate": "Resting heart rate", "metricHrv": "Heart-rate variability", "metricSleepDuration": "Sleep duration", @@ -9185,15 +9168,7 @@ "metricWristTemperature": "Overnight wrist temperature", "metricSkinTemperature": "Skin temperature", "metricBloodGlucose": "Blood glucose", - "metricMood": "Mood", - "unitHours": "h", - "unitSteps": "steps", - "unitBpm": "bpm", - "unitMs": "ms", - "unitMood": "pts", - "confidenceLow": "Low confidence", - "confidenceMedium": "Medium confidence", - "confidenceHigh": "High confidence" + "metricMood": "Mood" }, "title": "Cycle insights", "symptomPatternsTitle": "Symptom patterns by phase", diff --git a/messages/es.json b/messages/es.json index 2596d7dd2..a154ba9bd 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2597,7 +2597,8 @@ "complianceHeatmap": "Mapa de calor de cumplimiento de medicación de {start} a {end}, {days} días activos.", "complianceHeatmapEmpty": "Mapa de calor de cumplimiento de medicación, aún no hay días activos." }, - "medicationsEmptyAction": "Añadir un medicamento" + "medicationsEmptyAction": "Añadir un medicamento", + "rangeLabel": "Periodo" }, "chart": { "overlay": { @@ -4220,13 +4221,8 @@ "metricNextDayRecovery": "Recuperación al día siguiente", "metricNextDayRestingHr": "Frecuencia cardíaca en reposo al día siguiente", "metricNextDayHrv": "Variabilidad de la frecuencia cardíaca al día siguiente", - "unitHours": "h", - "unitKcal": "kcal", - "unitScore": "pts", - "unitBpm": "ppm", - "unitMs": "ms", - "detailSameDay": "Promedia {withAvg} {unit} en los {withDays} días que lo etiquetaste frente a {withoutAvg} {unit} sin él.", - "detailNextDay": "Promedia {withAvg} {unit} el día después de los {withDays} días que lo etiquetaste frente a {withoutAvg} {unit} el resto." + "detailSameDay": "Promedia {withAvg} en los {withDays} días que lo etiquetaste frente a {withoutAvg} sin él.", + "detailNextDay": "Promedia {withAvg} el día después de los {withDays} días que lo etiquetaste frente a {withoutAvg} el resto." }, "factorCrosstab": { "title": "Tus factores valorados y tus constantes", @@ -4238,15 +4234,8 @@ "metricHrv": "Variabilidad de la frecuencia cardíaca", "metricWeight": "Peso", "metricBloodPressureSys": "Sistólica", - "unitHours": "h", - "unitScore": "pts", - "unitSteps": "pasos", - "unitBpm": "ppm", - "unitMs": "ms", - "unitKg": "kg", - "unitMmHg": "mmHg", - "detail": "Promedia {lowAvg} {unit} en los {lowDays} días que valoraste {factor} bajo frente a {highAvg} {unit} en los días que lo valoraste alto.", - "detailInverse": "Promedia {lowAvg} {unit} en tus {lowDays} peores días de {factor} frente a {highAvg} {unit} en los mejores." + "detail": "Promedia {lowAvg} en los {lowDays} días que valoraste {factor} bajo frente a {highAvg} en los días que lo valoraste alto.", + "detailInverse": "Promedia {lowAvg} en tus {lowDays} peores días de {factor} frente a {highAvg} en los mejores." }, "discovery": { "title": "Lo que destaca", @@ -6673,21 +6662,12 @@ } }, "integrationPill": { - "connected": "Conectado", - "errorReconnect": "Error: reconectar", - "warningServerError": "Sincronización con errores", - "failureCount": "{count}/{threshold}", - "parkedReconnect": "Pausado: reconectar", - "notConnected": "No conectado", "justNow": "ahora mismo", "minutesAgo": "hace {count} min", "hoursAgo": "hace {count} h", "daysAgo": "hace {count} d", "resumeSuccess": "Integración reanudada", - "resumeError": "Reconexión fallida", - "stale": "Conectado, sin datos recientes", - "stalled": "Sincronización detenida", - "pendingSetup": "Esperando los primeros datos" + "resumeError": "Reconexión fallida" }, "integrationFreshness": { "title": "Datos recibidos", @@ -7756,6 +7736,7 @@ "tokensEmptyTitle": "Aún no hay tokens de API", "tokensEmptyDescription": "Los tokens se emiten cuando un cliente nativo inicia sesión o desde Ajustes → Cuenta.", "loginOverviewDescription": "Eventos de inicio de sesión y autenticación de todas las cuentas, filtrables y exportables como CSV.", + "loginOverviewFilterLabel": "Filtrar eventos", "allAuthEvents": "Todos los eventos de autenticación", "failedOnly": "Solo fallidos", "loginEmptyDescription": "Aquí aparecerán los inicios de sesión, los cierres de sesión y los cambios de passkey.", @@ -7884,6 +7865,7 @@ "admin": "Administradores", "user": "Usuarios" }, + "filterLabel": "Filtrar usuarios", "forceLogout": "Forzar cierre de sesión", "forceLogoutConfirmTitle": "¿Forzar el cierre de sesión de {name}?", "forceLogoutConfirmBody": "Se revocarán todas las sesiones activas de este usuario. Tendrá que volver a iniciar sesión en todos los dispositivos.", @@ -8909,6 +8891,7 @@ "inTarget": "En rango objetivo", "aboveTarget": "{delta} {unit} por encima del rango", "belowTarget": "{delta} {unit} por debajo del rango", + "systolic": "Sistólica", "diastolic": "Diastólica", "glucoseFasting": "Glucemia (en ayunas)", "glucosePostprandial": "Glucemia (posprandial)", @@ -9165,9 +9148,9 @@ "insights": { "headline": { "title": "Tu ciclo y tu cuerpo", - "restingHeartRate": "Tu frecuencia cardíaca en reposo es unos {delta} {unit} {dir} en tu fase lútea que en tu fase folicular.", - "heartRateVariability": "Tu variabilidad de la frecuencia cardíaca es unos {delta} {unit} {dir} en tu fase lútea que en tu fase folicular.", - "generic": "Tu {metric} es unos {delta} {unit} {dir} en tu fase lútea que en tu fase folicular.", + "restingHeartRate": "Tu frecuencia cardíaca en reposo es unos {delta} {dir} en tu fase lútea que en tu fase folicular.", + "heartRateVariability": "Tu variabilidad de la frecuencia cardíaca es unos {delta} {dir} en tu fase lútea que en tu fase folicular.", + "generic": "Tu {metric} es unos {delta} {dir} en tu fase lútea que en tu fase folicular.", "dirHigher": "más alta", "dirLower": "más baja", "empty": "Aún no hay suficientes ciclos ni registros para mostrar un patrón de fase. Sigue registrando: se completará a medida que crezca tu historial." @@ -9175,7 +9158,7 @@ "crosstab": { "description": "Cómo se compara cada medición entre tus fases lútea y folicular, con tus propios datos.", "pairLabel": "{metric}: fase lútea vs. folicular", - "detail": "Lútea {lutealAvg} {unit} ({lutealDays} días) vs. folicular {follicularAvg} {unit} ({follicularDays} días).", + "detail": "Lútea {lutealAvg} ({lutealDays} días) vs. folicular {follicularAvg} ({follicularDays} días).", "metricRestingHeartRate": "Frecuencia cardíaca en reposo", "metricHrv": "Variabilidad de la frecuencia cardíaca", "metricSleepDuration": "Duración del sueño", @@ -9185,15 +9168,7 @@ "metricWristTemperature": "Temperatura de la muñeca nocturna", "metricSkinTemperature": "Temperatura de la piel", "metricBloodGlucose": "Glucosa en sangre", - "metricMood": "Estado de ánimo", - "unitHours": "h", - "unitSteps": "pasos", - "unitBpm": "lpm", - "unitMs": "ms", - "unitMood": "pts", - "confidenceLow": "Confianza baja", - "confidenceMedium": "Confianza media", - "confidenceHigh": "Confianza alta" + "metricMood": "Estado de ánimo" }, "title": "Análisis del ciclo", "symptomPatternsTitle": "Patrones de síntomas por fase", diff --git a/messages/fr.json b/messages/fr.json index 89593958e..0807d9df1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2597,7 +2597,8 @@ "complianceHeatmap": "Carte thermique d'observance des médicaments du {start} au {end}, {days} jours actifs.", "complianceHeatmapEmpty": "Carte thermique d'observance des médicaments, aucun jour actif pour l'instant." }, - "medicationsEmptyAction": "Ajouter un médicament" + "medicationsEmptyAction": "Ajouter un médicament", + "rangeLabel": "Période" }, "chart": { "overlay": { @@ -4220,13 +4221,8 @@ "metricNextDayRecovery": "Récupération le lendemain", "metricNextDayRestingHr": "Fréquence cardiaque au repos le lendemain", "metricNextDayHrv": "Variabilité de la fréquence cardiaque le lendemain", - "unitHours": "h", - "unitKcal": "kcal", - "unitScore": "pts", - "unitBpm": "bpm", - "unitMs": "ms", - "detailSameDay": "En moyenne {withAvg} {unit} les {withDays} jours avec ce tag contre {withoutAvg} {unit} sans.", - "detailNextDay": "En moyenne {withAvg} {unit} le lendemain des {withDays} jours avec ce tag contre {withoutAvg} {unit} sinon." + "detailSameDay": "En moyenne {withAvg} les {withDays} jours avec ce tag contre {withoutAvg} sans.", + "detailNextDay": "En moyenne {withAvg} le lendemain des {withDays} jours avec ce tag contre {withoutAvg} sinon." }, "factorCrosstab": { "title": "Vos facteurs notés et vos constantes", @@ -4238,15 +4234,8 @@ "metricHrv": "Variabilité de la fréquence cardiaque", "metricWeight": "Poids", "metricBloodPressureSys": "Systolique", - "unitHours": "h", - "unitScore": "pts", - "unitSteps": "pas", - "unitBpm": "bpm", - "unitMs": "ms", - "unitKg": "kg", - "unitMmHg": "mmHg", - "detail": "En moyenne {lowAvg} {unit} les {lowDays} jours où vous avez noté {factor} bas contre {highAvg} {unit} les jours où vous l'avez noté haut.", - "detailInverse": "En moyenne {lowAvg} {unit} sur vos {lowDays} pires jours de {factor} contre {highAvg} {unit} sur les meilleurs." + "detail": "En moyenne {lowAvg} les {lowDays} jours où vous avez noté {factor} bas contre {highAvg} les jours où vous l'avez noté haut.", + "detailInverse": "En moyenne {lowAvg} sur vos {lowDays} pires jours de {factor} contre {highAvg} sur les meilleurs." }, "discovery": { "title": "Ce qui ressort", @@ -6673,21 +6662,12 @@ } }, "integrationPill": { - "connected": "Connecté", - "errorReconnect": "Erreur : reconnecter", - "warningServerError": "Échec de synchronisation", - "failureCount": "{count}/{threshold}", - "parkedReconnect": "En pause : reconnecter", - "notConnected": "Non connecté", "justNow": "à l’instant", "minutesAgo": "il y a {count} min", "hoursAgo": "il y a {count} h", "daysAgo": "il y a {count} j", "resumeSuccess": "Intégration reprise", - "resumeError": "Échec de la reconnexion", - "stale": "Connecté, aucune donnée récente", - "stalled": "Synchronisation arrêtée", - "pendingSetup": "En attente des premières données" + "resumeError": "Échec de la reconnexion" }, "integrationFreshness": { "title": "Données reçues", @@ -7756,6 +7736,7 @@ "tokensEmptyTitle": "Aucun jeton d'API pour l'instant", "tokensEmptyDescription": "Les jetons sont émis lorsqu'un client natif se connecte ou via Paramètres → Compte.", "loginOverviewDescription": "Événements de connexion et d’authentification de tous les comptes, filtrables et exportables en CSV.", + "loginOverviewFilterLabel": "Filtrer les événements", "allAuthEvents": "Tous les événements d'authentification", "failedOnly": "Échecs uniquement", "loginEmptyDescription": "Les connexions, déconnexions et modifications de passkey apparaîtront ici.", @@ -7884,6 +7865,7 @@ "admin": "Administrateurs", "user": "Utilisateurs" }, + "filterLabel": "Filtrer les utilisateurs", "forceLogout": "Forcer la déconnexion", "forceLogoutConfirmTitle": "Forcer la déconnexion de {name} ?", "forceLogoutConfirmBody": "Toutes les sessions actives de cet utilisateur seront révoquées. Il devra se reconnecter sur chaque appareil.", @@ -8909,6 +8891,7 @@ "inTarget": "Dans la plage cible", "aboveTarget": "{delta} {unit} au-dessus de la plage", "belowTarget": "{delta} {unit} en dessous de la plage", + "systolic": "Systolique", "diastolic": "Diastolique", "glucoseFasting": "Glycémie (à jeun)", "glucosePostprandial": "Glycémie (postprandiale)", @@ -9165,9 +9148,9 @@ "insights": { "headline": { "title": "Ton cycle et ton corps", - "restingHeartRate": "Ta fréquence cardiaque au repos est d'environ {delta} {unit} {dir} en phase lutéale qu'en phase folliculaire.", - "heartRateVariability": "Ta variabilité de la fréquence cardiaque est d'environ {delta} {unit} {dir} en phase lutéale qu'en phase folliculaire.", - "generic": "Ton/ta {metric} est d'environ {delta} {unit} {dir} en phase lutéale qu'en phase folliculaire.", + "restingHeartRate": "Ta fréquence cardiaque au repos est d'environ {delta} {dir} en phase lutéale qu'en phase folliculaire.", + "heartRateVariability": "Ta variabilité de la fréquence cardiaque est d'environ {delta} {dir} en phase lutéale qu'en phase folliculaire.", + "generic": "Ton/ta {metric} est d'environ {delta} {dir} en phase lutéale qu'en phase folliculaire.", "dirHigher": "plus élevée", "dirLower": "plus basse", "empty": "Pas encore assez de cycles et de relevés pour faire ressortir un schéma de phase. Continue à enregistrer : cela se complétera à mesure que ton historique grandit." @@ -9175,7 +9158,7 @@ "crosstab": { "description": "Comment chaque mesure se compare entre tes phases lutéale et folliculaire, sur tes propres données.", "pairLabel": "{metric} : phase lutéale vs folliculaire", - "detail": "Lutéale {lutealAvg} {unit} ({lutealDays} jours) vs folliculaire {follicularAvg} {unit} ({follicularDays} jours).", + "detail": "Lutéale {lutealAvg} ({lutealDays} jours) vs folliculaire {follicularAvg} ({follicularDays} jours).", "metricRestingHeartRate": "Fréquence cardiaque au repos", "metricHrv": "Variabilité de la fréquence cardiaque", "metricSleepDuration": "Durée du sommeil", @@ -9185,15 +9168,7 @@ "metricWristTemperature": "Température du poignet la nuit", "metricSkinTemperature": "Température de la peau", "metricBloodGlucose": "Glycémie", - "metricMood": "Humeur", - "unitHours": "h", - "unitSteps": "pas", - "unitBpm": "bpm", - "unitMs": "ms", - "unitMood": "pts", - "confidenceLow": "Confiance faible", - "confidenceMedium": "Confiance moyenne", - "confidenceHigh": "Confiance élevée" + "metricMood": "Humeur" }, "title": "Analyses du cycle", "symptomPatternsTitle": "Tendances des symptômes par phase", diff --git a/messages/it.json b/messages/it.json index 16190ea09..52629264f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2597,7 +2597,8 @@ "complianceHeatmap": "Mappa di calore dell'aderenza ai farmaci dal {start} al {end}, {days} giorni attivi.", "complianceHeatmapEmpty": "Mappa di calore dell'aderenza ai farmaci, nessun giorno attivo." }, - "medicationsEmptyAction": "Aggiungi un farmaco" + "medicationsEmptyAction": "Aggiungi un farmaco", + "rangeLabel": "Periodo" }, "chart": { "overlay": { @@ -4220,13 +4221,8 @@ "metricNextDayRecovery": "Recupero il giorno dopo", "metricNextDayRestingHr": "Frequenza cardiaca a riposo il giorno dopo", "metricNextDayHrv": "Variabilità della frequenza cardiaca il giorno dopo", - "unitHours": "h", - "unitKcal": "kcal", - "unitScore": "pt", - "unitBpm": "bpm", - "unitMs": "ms", - "detailSameDay": "In media {withAvg} {unit} nei {withDays} giorni con questo tag contro {withoutAvg} {unit} senza.", - "detailNextDay": "In media {withAvg} {unit} il giorno dopo i {withDays} giorni con questo tag contro {withoutAvg} {unit} altrimenti." + "detailSameDay": "In media {withAvg} nei {withDays} giorni con questo tag contro {withoutAvg} senza.", + "detailNextDay": "In media {withAvg} il giorno dopo i {withDays} giorni con questo tag contro {withoutAvg} altrimenti." }, "factorCrosstab": { "title": "I tuoi fattori valutati e i tuoi parametri vitali", @@ -4238,15 +4234,8 @@ "metricHrv": "Variabilità della frequenza cardiaca", "metricWeight": "Peso", "metricBloodPressureSys": "Sistolica", - "unitHours": "h", - "unitScore": "pti", - "unitSteps": "passi", - "unitBpm": "bpm", - "unitMs": "ms", - "unitKg": "kg", - "unitMmHg": "mmHg", - "detail": "In media {lowAvg} {unit} nei {lowDays} giorni in cui hai valutato basso {factor} contro {highAvg} {unit} nei giorni in cui lo hai valutato alto.", - "detailInverse": "In media {lowAvg} {unit} nei tuoi {lowDays} giorni peggiori di {factor} contro {highAvg} {unit} in quelli migliori." + "detail": "In media {lowAvg} nei {lowDays} giorni in cui hai valutato basso {factor} contro {highAvg} nei giorni in cui lo hai valutato alto.", + "detailInverse": "In media {lowAvg} nei tuoi {lowDays} giorni peggiori di {factor} contro {highAvg} in quelli migliori." }, "discovery": { "title": "Cosa risalta", @@ -6673,21 +6662,12 @@ } }, "integrationPill": { - "connected": "Connesso", - "errorReconnect": "Errore, riconnetti", - "warningServerError": "Sincronizzazione in errore", - "failureCount": "{count}/{threshold}", - "parkedReconnect": "In pausa, riconnetti", - "notConnected": "Non connesso", "justNow": "proprio ora", "minutesAgo": "{count} min fa", "hoursAgo": "{count} h fa", "daysAgo": "{count} g fa", "resumeSuccess": "Integrazione ripresa", - "resumeError": "Riconnessione fallita", - "stale": "Connesso, nessun dato recente", - "stalled": "Sincronizzazione ferma", - "pendingSetup": "In attesa dei primi dati" + "resumeError": "Riconnessione fallita" }, "integrationFreshness": { "title": "Dati ricevuti", @@ -7756,6 +7736,7 @@ "tokensEmptyTitle": "Ancora nessun token API", "tokensEmptyDescription": "I token vengono emessi quando un client nativo accede o tramite Impostazioni → Account.", "loginOverviewDescription": "Eventi di accesso e autenticazione di tutti gli account, filtrabili ed esportabili in CSV.", + "loginOverviewFilterLabel": "Filtra eventi", "allAuthEvents": "Tutti gli eventi di autenticazione", "failedOnly": "Solo non riusciti", "loginEmptyDescription": "Qui compariranno accessi, disconnessioni e modifiche delle passkey.", @@ -7884,6 +7865,7 @@ "admin": "Amministratori", "user": "Utenti" }, + "filterLabel": "Filtra utenti", "forceLogout": "Forza la disconnessione", "forceLogoutConfirmTitle": "Forzare la disconnessione di {name}?", "forceLogoutConfirmBody": "Tutte le sessioni attive di questo utente verranno revocate. Dovrà accedere di nuovo su ogni dispositivo.", @@ -8909,6 +8891,7 @@ "inTarget": "Nel range obiettivo", "aboveTarget": "{delta} {unit} sopra il range", "belowTarget": "{delta} {unit} sotto il range", + "systolic": "Sistolica", "diastolic": "Diastolica", "glucoseFasting": "Glicemia (a digiuno)", "glucosePostprandial": "Glicemia (postprandiale)", @@ -9165,9 +9148,9 @@ "insights": { "headline": { "title": "Il tuo ciclo e il tuo corpo", - "restingHeartRate": "La tua frequenza cardiaca a riposo è di circa {delta} {unit} {dir} nella fase luteale rispetto alla fase follicolare.", - "heartRateVariability": "La tua variabilità della frequenza cardiaca è di circa {delta} {unit} {dir} nella fase luteale rispetto alla fase follicolare.", - "generic": "Il/la tuo/a {metric} è di circa {delta} {unit} {dir} nella fase luteale rispetto alla fase follicolare.", + "restingHeartRate": "La tua frequenza cardiaca a riposo è di circa {delta} {dir} nella fase luteale rispetto alla fase follicolare.", + "heartRateVariability": "La tua variabilità della frequenza cardiaca è di circa {delta} {dir} nella fase luteale rispetto alla fase follicolare.", + "generic": "Il/la tuo/a {metric} è di circa {delta} {dir} nella fase luteale rispetto alla fase follicolare.", "dirHigher": "più alta", "dirLower": "più bassa", "empty": "Non ci sono ancora abbastanza cicli e rilevazioni per mostrare uno schema di fase. Continua a registrare: si completerà man mano che cresce il tuo storico." @@ -9175,7 +9158,7 @@ "crosstab": { "description": "Come si confronta ogni misurazione tra la fase luteale e quella follicolare, sui tuoi dati.", "pairLabel": "{metric}: fase luteale vs follicolare", - "detail": "Luteale {lutealAvg} {unit} ({lutealDays} giorni) vs follicolare {follicularAvg} {unit} ({follicularDays} giorni).", + "detail": "Luteale {lutealAvg} ({lutealDays} giorni) vs follicolare {follicularAvg} ({follicularDays} giorni).", "metricRestingHeartRate": "Frequenza cardiaca a riposo", "metricHrv": "Variabilità della frequenza cardiaca", "metricSleepDuration": "Durata del sonno", @@ -9185,15 +9168,7 @@ "metricWristTemperature": "Temperatura del polso notturna", "metricSkinTemperature": "Temperatura della pelle", "metricBloodGlucose": "Glicemia", - "metricMood": "Umore", - "unitHours": "h", - "unitSteps": "passi", - "unitBpm": "bpm", - "unitMs": "ms", - "unitMood": "pts", - "confidenceLow": "Affidabilità bassa", - "confidenceMedium": "Affidabilità media", - "confidenceHigh": "Affidabilità alta" + "metricMood": "Umore" }, "title": "Analisi del ciclo", "symptomPatternsTitle": "Pattern dei sintomi per fase", diff --git a/messages/ko.json b/messages/ko.json index 022fde09c..3b245cf71 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -2597,7 +2597,8 @@ "days90Title": "최근 90일", "daysAllLabel": "전체", "daysAllTitle": "전체 기간", - "medicationsEmptyAction": "약 추가하기" + "medicationsEmptyAction": "약 추가하기", + "rangeLabel": "기간" }, "chart": { "overlay": { @@ -4220,13 +4221,8 @@ "metricNextDayRecovery": "다음 날 회복도", "metricNextDayRestingHr": "다음 날 안정 시 심박수", "metricNextDayHrv": "다음 날 심박변이도", - "unitHours": "h", - "unitKcal": "kcal", - "unitScore": "점", - "unitBpm": "bpm", - "unitMs": "ms", - "detailSameDay": "이 태그를 붙인 {withDays}일의 평균은 {withAvg}{unit}, 붙이지 않은 날의 평균은 {withoutAvg}{unit}.", - "detailNextDay": "이 태그를 붙인 {withDays}일의 다음 날 평균은 {withAvg}{unit}, 그 외의 날은 {withoutAvg}{unit}." + "detailSameDay": "이 태그를 붙인 {withDays}일의 평균은 {withAvg}, 붙이지 않은 날의 평균은 {withoutAvg}.", + "detailNextDay": "이 태그를 붙인 {withDays}일의 다음 날 평균은 {withAvg}, 그 외의 날은 {withoutAvg}." }, "factorCrosstab": { "title": "직접 평가한 요인과 생체 지표", @@ -4238,15 +4234,8 @@ "metricHrv": "심박변이도", "metricWeight": "체중", "metricBloodPressureSys": "수축기", - "unitHours": "h", - "unitScore": "점", - "unitSteps": "걸음", - "unitBpm": "bpm", - "unitMs": "ms", - "unitKg": "kg", - "unitMmHg": "mmHg", - "detail": "{factor} 평가가 낮았던 {lowDays}일의 평균은 {lowAvg}{unit}, 높게 평가한 날의 평균은 {highAvg}{unit}.", - "detailInverse": "{factor} 상태가 나빴던 {lowDays}일의 평균은 {lowAvg}{unit}, 좋았던 날의 평균은 {highAvg}{unit}." + "detail": "{factor} 평가가 낮았던 {lowDays}일의 평균은 {lowAvg}, 높게 평가한 날의 평균은 {highAvg}.", + "detailInverse": "{factor} 상태가 나빴던 {lowDays}일의 평균은 {lowAvg}, 좋았던 날의 평균은 {highAvg}." }, "discovery": { "title": "눈에 띄는 점", @@ -6673,21 +6662,12 @@ } }, "integrationPill": { - "connected": "연결됨", - "errorReconnect": "오류: 다시 연결", - "warningServerError": "동기화 실패 중", - "failureCount": "{count}/{threshold}", - "parkedReconnect": "일시중지: 다시 연결", - "notConnected": "연결 안 됨", "justNow": "방금", "minutesAgo": "{count}분 전", "hoursAgo": "{count}시간 전", "daysAgo": "{count}일 전", "resumeSuccess": "연동을 다시 시작했어요", - "resumeError": "다시 연결하지 못했어요", - "stale": "연결됨, 최근 데이터 없음", - "stalled": "동기화 멈춤", - "pendingSetup": "첫 데이터 기다리는 중" + "resumeError": "다시 연결하지 못했어요" }, "integrationFreshness": { "title": "받은 데이터", @@ -7731,6 +7711,7 @@ "tokensEmptyTitle": "아직 API 토큰이 없어요", "tokensEmptyDescription": "네이티브 클라이언트가 로그인할 때나 설정 → 계정에서 토큰이 발급돼요.", "loginOverviewDescription": "모든 계정의 로그인·인증 이벤트예요. 필터링하고 CSV로 내보낼 수 있어요.", + "loginOverviewFilterLabel": "이벤트 필터", "allAuthEvents": "전체 인증 이벤트", "failedOnly": "실패만", "loginEmptyDescription": "로그인, 로그아웃, 패스키 변경이 여기에 표시돼요.", @@ -7859,6 +7840,7 @@ "admin": "관리자", "user": "사용자" }, + "filterLabel": "사용자 필터", "forceLogout": "강제 로그아웃", "forceLogoutConfirmTitle": "{name} 강제 로그아웃할까요?", "forceLogoutConfirmBody": "이 사용자의 활성 세션이 모두 해제돼요. 모든 기기에서 다시 로그인해야 해요.", @@ -8909,6 +8891,7 @@ "inTarget": "목표 범위 안", "aboveTarget": "목표보다 {delta} {unit} 높음", "belowTarget": "목표보다 {delta} {unit} 낮음", + "systolic": "수축기", "diastolic": "이완기", "glucoseFasting": "혈당 (공복)", "glucosePostprandial": "혈당 (식후)", @@ -9165,9 +9148,9 @@ "insights": { "headline": { "title": "내 주기와 내 몸", - "restingHeartRate": "황체기의 안정 시 심박수가 난포기보다 약 {delta}{unit} {dir}.", - "heartRateVariability": "황체기의 심박변이도가 난포기보다 약 {delta}{unit} {dir}.", - "generic": "황체기의 {metric} 값이 난포기보다 약 {delta}{unit} {dir}.", + "restingHeartRate": "황체기의 안정 시 심박수가 난포기보다 약 {delta} {dir}.", + "heartRateVariability": "황체기의 심박변이도가 난포기보다 약 {delta} {dir}.", + "generic": "황체기의 {metric} 값이 난포기보다 약 {delta} {dir}.", "dirHigher": "높아요", "dirLower": "낮아요", "empty": "아직 주기와 측정값이 부족해서 단계별 패턴을 보여줄 수 없어요. 계속 기록하면 기록이 쌓이면서 채워져요." @@ -9175,7 +9158,7 @@ "crosstab": { "description": "내 데이터를 기준으로, 각 측정값이 황체기와 난포기에서 어떻게 다른지 비교했어요.", "pairLabel": "{metric}: 황체기와 난포기 비교", - "detail": "황체기 {lutealAvg}{unit} ({lutealDays}일), 난포기 {follicularAvg}{unit} ({follicularDays}일).", + "detail": "황체기 {lutealAvg} ({lutealDays}일), 난포기 {follicularAvg} ({follicularDays}일).", "metricRestingHeartRate": "안정 시 심박수", "metricHrv": "심박변이도", "metricSleepDuration": "수면 시간", @@ -9185,15 +9168,7 @@ "metricWristTemperature": "야간 손목 온도", "metricSkinTemperature": "피부 온도", "metricBloodGlucose": "혈당", - "metricMood": "기분", - "unitHours": "h", - "unitSteps": "걸음", - "unitBpm": "bpm", - "unitMs": "ms", - "unitMood": "점", - "confidenceLow": "신뢰도 낮음", - "confidenceMedium": "신뢰도 보통", - "confidenceHigh": "신뢰도 높음" + "metricMood": "기분" }, "title": "주기 인사이트", "symptomPatternsTitle": "단계별 증상 패턴", diff --git a/messages/nl.json b/messages/nl.json index 9a519e18a..6732ea4dd 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -2597,7 +2597,8 @@ "complianceHeatmap": "Heatmap van therapietrouw van {start} tot {end}, {days} actieve dagen.", "complianceHeatmapEmpty": "Heatmap van therapietrouw, nog geen actieve dagen." }, - "medicationsEmptyAction": "Medicijn toevoegen" + "medicationsEmptyAction": "Medicijn toevoegen", + "rangeLabel": "Periode" }, "chart": { "overlay": { @@ -4220,13 +4221,8 @@ "metricNextDayRecovery": "Herstel de volgende dag", "metricNextDayRestingHr": "Rusthartslag de volgende dag", "metricNextDayHrv": "Hartslagvariabiliteit de volgende dag", - "unitHours": "u", - "unitKcal": "kcal", - "unitScore": "ptn", - "unitBpm": "bpm", - "unitMs": "ms", - "detailSameDay": "Gemiddeld {withAvg} {unit} op de {withDays} dagen dat je dit hebt getagd, tegenover {withoutAvg} {unit} zonder.", - "detailNextDay": "Gemiddeld {withAvg} {unit} op de dag na de {withDays} dagen dat je dit hebt getagd, tegenover {withoutAvg} {unit} op andere dagen." + "detailSameDay": "Gemiddeld {withAvg} op de {withDays} dagen dat je dit hebt getagd, tegenover {withoutAvg} zonder.", + "detailNextDay": "Gemiddeld {withAvg} op de dag na de {withDays} dagen dat je dit hebt getagd, tegenover {withoutAvg} op andere dagen." }, "factorCrosstab": { "title": "Je beoordeelde factoren en je vitale waarden", @@ -4238,15 +4234,8 @@ "metricHrv": "Hartslagvariabiliteit", "metricWeight": "Gewicht", "metricBloodPressureSys": "Systolisch", - "unitHours": "u", - "unitScore": "ptn", - "unitSteps": "stappen", - "unitBpm": "bpm", - "unitMs": "ms", - "unitKg": "kg", - "unitMmHg": "mmHg", - "detail": "Gemiddeld {lowAvg} {unit} op de {lowDays} dagen dat je {factor} laag beoordeelde, tegenover {highAvg} {unit} op de dagen dat je die hoog beoordeelde.", - "detailInverse": "Gemiddeld {lowAvg} {unit} op je {lowDays} slechtere dagen voor {factor}, tegenover {highAvg} {unit} op je betere dagen." + "detail": "Gemiddeld {lowAvg} op de {lowDays} dagen dat je {factor} laag beoordeelde, tegenover {highAvg} op de dagen dat je die hoog beoordeelde.", + "detailInverse": "Gemiddeld {lowAvg} op je {lowDays} slechtere dagen voor {factor}, tegenover {highAvg} op je betere dagen." }, "discovery": { "title": "Wat opvalt", @@ -6673,21 +6662,12 @@ } }, "integrationPill": { - "connected": "Gekoppeld", - "errorReconnect": "Fout: opnieuw koppelen", - "warningServerError": "Synchronisatie mislukt", - "failureCount": "{count}/{threshold}", - "parkedReconnect": "Gepauzeerd: opnieuw koppelen", - "notConnected": "Niet gekoppeld", "justNow": "zojuist", "minutesAgo": "{count} min geleden", "hoursAgo": "{count} u geleden", "daysAgo": "{count} d geleden", "resumeSuccess": "Koppeling hervat", - "resumeError": "Opnieuw koppelen mislukt", - "stale": "Gekoppeld, geen recente gegevens", - "stalled": "Synchronisatie gestopt", - "pendingSetup": "Wachten op eerste gegevens" + "resumeError": "Opnieuw koppelen mislukt" }, "integrationFreshness": { "title": "Ontvangen gegevens", @@ -7756,6 +7736,7 @@ "tokensEmptyTitle": "Nog geen API-tokens", "tokensEmptyDescription": "Tokens worden uitgegeven wanneer een native client inlogt of via Instellingen → Account.", "loginOverviewDescription": "Inlog- en authenticatiegebeurtenissen van alle accounts, te filteren en te exporteren als CSV.", + "loginOverviewFilterLabel": "Gebeurtenissen filteren", "allAuthEvents": "Alle authenticatiegebeurtenissen", "failedOnly": "Alleen mislukte", "loginEmptyDescription": "Inlogs, uitlogs en wijzigingen aan passkeys verschijnen hier.", @@ -7884,6 +7865,7 @@ "admin": "Beheerders", "user": "Gebruikers" }, + "filterLabel": "Gebruikers filteren", "forceLogout": "Geforceerd uitloggen", "forceLogoutConfirmTitle": "{name} geforceerd uitloggen?", "forceLogoutConfirmBody": "Alle actieve sessies van deze gebruiker worden ingetrokken. Deze gebruiker moet daarna op elk apparaat opnieuw inloggen.", @@ -8909,6 +8891,7 @@ "inTarget": "Binnen het streefbereik", "aboveTarget": "{delta} {unit} boven het doel", "belowTarget": "{delta} {unit} onder het doel", + "systolic": "Systolisch", "diastolic": "Diastolisch", "glucoseFasting": "Glucose (nuchter)", "glucosePostprandial": "Glucose (na de maaltijd)", @@ -9165,9 +9148,9 @@ "insights": { "headline": { "title": "Je cyclus en je lichaam", - "restingHeartRate": "Je rusthartslag ligt in je luteale fase ongeveer {delta} {unit} {dir} dan in je folliculaire fase.", - "heartRateVariability": "Je hartslagvariabiliteit ligt in je luteale fase ongeveer {delta} {unit} {dir} dan in je folliculaire fase.", - "generic": "Je {metric} ligt in je luteale fase ongeveer {delta} {unit} {dir} dan in je folliculaire fase.", + "restingHeartRate": "Je rusthartslag ligt in je luteale fase ongeveer {delta} {dir} dan in je folliculaire fase.", + "heartRateVariability": "Je hartslagvariabiliteit ligt in je luteale fase ongeveer {delta} {dir} dan in je folliculaire fase.", + "generic": "Je {metric} ligt in je luteale fase ongeveer {delta} {dir} dan in je folliculaire fase.", "dirHigher": "hoger", "dirLower": "lager", "empty": "Nog niet genoeg cycli en metingen om een patroon per fase te tonen. Blijf vastleggen; dit vult zich naarmate je geschiedenis groeit." @@ -9175,7 +9158,7 @@ "crosstab": { "description": "Hoe elke meting zich verhoudt tussen je luteale en folliculaire fase, op basis van je eigen gegevens.", "pairLabel": "{metric}: luteaal vs. folliculair", - "detail": "Luteaal {lutealAvg} {unit} ({lutealDays} dagen) vs. folliculair {follicularAvg} {unit} ({follicularDays} dagen).", + "detail": "Luteaal {lutealAvg} ({lutealDays} dagen) vs. folliculair {follicularAvg} ({follicularDays} dagen).", "metricRestingHeartRate": "Rusthartslag", "metricHrv": "Hartslagvariabiliteit", "metricSleepDuration": "Slaapduur", @@ -9185,15 +9168,7 @@ "metricWristTemperature": "Nachtelijke polstemperatuur", "metricSkinTemperature": "Huidtemperatuur", "metricBloodGlucose": "Bloedglucose", - "metricMood": "Stemming", - "unitHours": "u", - "unitSteps": "stappen", - "unitBpm": "bpm", - "unitMs": "ms", - "unitMood": "pnt", - "confidenceLow": "Lage betrouwbaarheid", - "confidenceMedium": "Gemiddelde betrouwbaarheid", - "confidenceHigh": "Hoge betrouwbaarheid" + "metricMood": "Stemming" }, "title": "Cyclusinzichten", "symptomPatternsTitle": "Symptoompatronen per fase", diff --git a/messages/pl.json b/messages/pl.json index 3c2c5f26e..21304ed2c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2597,7 +2597,8 @@ "complianceHeatmap": "Mapa cieplna przyjmowania leków od {start} do {end}, {days} aktywnych dni.", "complianceHeatmapEmpty": "Mapa cieplna przyjmowania leków, brak aktywnych dni." }, - "medicationsEmptyAction": "Dodaj lek" + "medicationsEmptyAction": "Dodaj lek", + "rangeLabel": "Zakres czasu" }, "chart": { "overlay": { @@ -4220,13 +4221,8 @@ "metricNextDayRecovery": "Regeneracja następnego dnia", "metricNextDayRestingHr": "Tętno spoczynkowe następnego dnia", "metricNextDayHrv": "Zmienność rytmu serca następnego dnia", - "unitHours": "godz.", - "unitKcal": "kcal", - "unitScore": "pkt", - "unitBpm": "bpm", - "unitMs": "ms", - "detailSameDay": "Średnio {withAvg} {unit} w {withDays} dniach z tym tagiem wobec {withoutAvg} {unit} bez niego.", - "detailNextDay": "Średnio {withAvg} {unit} dzień po {withDays} dniach z tym tagiem wobec {withoutAvg} {unit} w pozostałe dni." + "detailSameDay": "Średnio {withAvg} w {withDays} dniach z tym tagiem wobec {withoutAvg} bez niego.", + "detailNextDay": "Średnio {withAvg} dzień po {withDays} dniach z tym tagiem wobec {withoutAvg} w pozostałe dni." }, "factorCrosstab": { "title": "Twoje ocenione czynniki i parametry życiowe", @@ -4238,15 +4234,8 @@ "metricHrv": "Zmienność rytmu serca", "metricWeight": "Waga", "metricBloodPressureSys": "Skurczowe", - "unitHours": "godz.", - "unitScore": "pkt", - "unitSteps": "kroki", - "unitBpm": "bpm", - "unitMs": "ms", - "unitKg": "kg", - "unitMmHg": "mmHg", - "detail": "Średnio {lowAvg} {unit} w {lowDays} dniach z niską oceną {factor} wobec {highAvg} {unit} w dniach z wysoką oceną.", - "detailInverse": "Średnio {lowAvg} {unit} w Twoich {lowDays} gorszych dniach {factor} wobec {highAvg} {unit} w lepszych." + "detail": "Średnio {lowAvg} w {lowDays} dniach z niską oceną {factor} wobec {highAvg} w dniach z wysoką oceną.", + "detailInverse": "Średnio {lowAvg} w Twoich {lowDays} gorszych dniach {factor} wobec {highAvg} w lepszych." }, "discovery": { "title": "Co się wyróżnia", @@ -6673,21 +6662,12 @@ } }, "integrationPill": { - "connected": "Połączono", - "errorReconnect": "Błąd: połącz ponownie", - "warningServerError": "Synchronizacja nie działa", - "failureCount": "{count}/{threshold}", - "parkedReconnect": "Wstrzymane: połącz ponownie", - "notConnected": "Nie połączono", "justNow": "przed chwilą", "minutesAgo": "{count} min temu", "hoursAgo": "{count} godz. temu", "daysAgo": "{count} dni temu", "resumeSuccess": "Integracja wznowiona", - "resumeError": "Ponowne połączenie nieudane", - "stale": "Połączono, brak nowych danych", - "stalled": "Synchronizacja zatrzymana", - "pendingSetup": "Czekam na pierwsze dane" + "resumeError": "Ponowne połączenie nieudane" }, "integrationFreshness": { "title": "Odebrane dane", @@ -7756,6 +7736,7 @@ "tokensEmptyTitle": "Brak tokenów API", "tokensEmptyDescription": "Tokeny są wydawane, gdy klient natywny się loguje, lub przez Ustawienia → Konto.", "loginOverviewDescription": "Zdarzenia logowania i uwierzytelniania wszystkich kont, z filtrowaniem i eksportem CSV.", + "loginOverviewFilterLabel": "Filtruj zdarzenia", "allAuthEvents": "Wszystkie zdarzenia uwierzytelniania", "failedOnly": "Tylko nieudane", "loginEmptyDescription": "Tutaj pojawią się logowania, wylogowania i zmiany kluczy passkey.", @@ -7884,6 +7865,7 @@ "admin": "Administratorzy", "user": "Użytkownicy" }, + "filterLabel": "Filtruj użytkowników", "forceLogout": "Wymuś wylogowanie", "forceLogoutConfirmTitle": "Wymusić wylogowanie {name}?", "forceLogoutConfirmBody": "Wszystkie aktywne sesje tego użytkownika zostaną unieważnione. Będzie musiał zalogować się ponownie na każdym urządzeniu.", @@ -8909,6 +8891,7 @@ "inTarget": "W zakresie docelowym", "aboveTarget": "{delta} {unit} powyżej zakresu", "belowTarget": "{delta} {unit} poniżej zakresu", + "systolic": "Skurczowe", "diastolic": "Rozkurczowe", "glucoseFasting": "Glikemia: na czczo", "glucosePostprandial": "Glikemia: po posiłku", @@ -9165,9 +9148,9 @@ "insights": { "headline": { "title": "Twój cykl i Twoje ciało", - "restingHeartRate": "Twoje tętno spoczynkowe jest o około {delta} {unit} {dir} w fazie lutealnej niż w fazie folikularnej.", - "heartRateVariability": "Twoja zmienność rytmu serca jest o około {delta} {unit} {dir} w fazie lutealnej niż w fazie folikularnej.", - "generic": "Twoje {metric} jest o około {delta} {unit} {dir} w fazie lutealnej niż w fazie folikularnej.", + "restingHeartRate": "Twoje tętno spoczynkowe jest o około {delta} {dir} w fazie lutealnej niż w fazie folikularnej.", + "heartRateVariability": "Twoja zmienność rytmu serca jest o około {delta} {dir} w fazie lutealnej niż w fazie folikularnej.", + "generic": "Twoje {metric} jest o około {delta} {dir} w fazie lutealnej niż w fazie folikularnej.", "dirHigher": "wyższe", "dirLower": "niższe", "empty": "Za mało cykli i pomiarów, aby pokazać wzorzec fazy. Rejestruj dalej, a uzupełni się to wraz z historią." @@ -9175,7 +9158,7 @@ "crosstab": { "description": "Jak każdy pomiar wypada między fazą lutealną a folikularną, na Twoich danych.", "pairLabel": "{metric}: faza lutealna vs folikularna", - "detail": "Lutealna {lutealAvg} {unit} ({lutealDays} dni) vs folikularna {follicularAvg} {unit} ({follicularDays} dni).", + "detail": "Lutealna {lutealAvg} ({lutealDays} dni) vs folikularna {follicularAvg} ({follicularDays} dni).", "metricRestingHeartRate": "Tętno spoczynkowe", "metricHrv": "Zmienność rytmu serca", "metricSleepDuration": "Czas snu", @@ -9185,15 +9168,7 @@ "metricWristTemperature": "Nocna temperatura nadgarstka", "metricSkinTemperature": "Temperatura skóry", "metricBloodGlucose": "Glukoza we krwi", - "metricMood": "Nastrój", - "unitHours": "godz.", - "unitSteps": "kroki", - "unitBpm": "ud./min", - "unitMs": "ms", - "unitMood": "pkt", - "confidenceLow": "Niska pewność", - "confidenceMedium": "Średnia pewność", - "confidenceHigh": "Wysoka pewność" + "metricMood": "Nastrój" }, "title": "Analizy cyklu", "symptomPatternsTitle": "Wzorce objawów wg fazy", diff --git a/package.json b/package.json index ee1992755..f5567108f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.43.7", + "version": "1.43.8", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/prisma/migrations/0414_drop_medication_compliance_rollups/migration.sql b/prisma/migrations/0414_drop_medication_compliance_rollups/migration.sql new file mode 100644 index 000000000..e5894b517 --- /dev/null +++ b/prisma/migrations/0414_drop_medication_compliance_rollups/migration.sql @@ -0,0 +1,32 @@ +-- Drop `medication_compliance_rollups`. +-- +-- The per-(user, medication, day) coverage ledger lost its last reader when +-- the dashboard tile and the compliance views moved to the schedule-anchored +-- computation; every dose write still upserted a row into it, a boot backfill +-- refolded it, and it travelled through the data wipe for nothing. The write +-- hook now refreshes only what still derives from a dose day (the supplement +-- nutrients), and the boot retires the backfill queue. +-- +-- Nothing is lost: the table held counts derived from +-- `medication_intake_events`, which stay. +-- +-- Lock wait. Dropping the table removes its two foreign keys, and removing a +-- foreign key's triggers takes ACCESS EXCLUSIVE on the referenced tables, +-- `users` and `medications`, for the instant the catalog changes. Waiting for +-- that lock behind a long reader would queue every login and every +-- medication read behind the migration, so the wait is bounded at five +-- seconds inside one `DO` block (one statement, so it commits or rolls back +-- whole; `migrate deploy` runs a file statement by statement). A lock +-- timeout changes nothing and the file is safe to apply again, which the +-- marker declares (docs/ops/deploy.md, "A migration that timed out on a +-- lock"). +-- +-- @retry-on-lock-timeout + +DO $migration$ +BEGIN + PERFORM set_config('lock_timeout', '5s', true); + + DROP TABLE IF EXISTS "medication_compliance_rollups"; +END +$migration$; diff --git a/prisma/migrations/0415_foreign_key_and_prefix_indexes/migration.sql b/prisma/migrations/0415_foreign_key_and_prefix_indexes/migration.sql new file mode 100644 index 000000000..eb7fb4676 --- /dev/null +++ b/prisma/migrations/0415_foreign_key_and_prefix_indexes/migration.sql @@ -0,0 +1,107 @@ +-- Index hygiene: a leading index for every foreign key that cascades, and +-- no index that is a prefix of another. +-- +-- 1. Twenty-one foreign keys with ON DELETE CASCADE or SET NULL had no index +-- leading with their column. Deleting the parent row (a medication, a +-- schedule, a practitioner, an account) then scans the whole child table +-- once per deleted parent: `medication_intake_events.medication_id` is the +-- one that hurts, scanned in full for every medication an account delete +-- removes. Each gets a plain btree on the column. +-- +-- 2. Seventeen indexes repeated the leading columns of another index on the +-- same table, in the same order and direction, mostly a `(user_id)` index +-- beside a `(user_id, …)` unique key. The longer index answers every +-- lookup the shorter one did, so the shorter one only cost writes and +-- space. They go. `measurements` has its own migration (0416), because a +-- lock on that table weighs differently. +-- +-- `tests/integration/index-hygiene.test.ts` holds the schema to both rules +-- from the catalog. +-- +-- Lock wait. `CREATE INDEX` takes SHARE on its table (reads go on, writes +-- wait while it builds; every table here is small, so the build is quick) +-- and `DROP INDEX` takes ACCESS EXCLUSIVE. `CREATE INDEX CONCURRENTLY` +-- cannot share a migration file with any other statement, so the whole set +-- runs in one `DO` block: one statement that commits or rolls back whole, +-- with each lock wait bounded at five seconds so a long reader cannot queue +-- the tables' writes behind the migration. Inside the block every lock is +-- held until it commits, not just while its statement runs: the SHARE locks +-- of the builds stay through the drops, and the drops come last so their +-- ACCESS EXCLUSIVE locks are held only for the rest of the block. An app +-- transaction that takes two of these tables in the other order can +-- therefore deadlock with it; Postgres cancels one side, and when that is the +-- migration its block has rolled back whole, as on a lock timeout. Every +-- statement is `IF [NOT] EXISTS`, so either failure leaves the file safe to +-- apply again, which the marker declares and the boot acts on for both +-- (docs/ops/deploy.md, "A migration that timed out on a lock"). +-- +-- @retry-on-lock-timeout + +DO $migration$ +BEGIN + PERFORM set_config('lock_timeout', '5s', true); + + -- 1. Foreign keys without a leading index. + CREATE INDEX IF NOT EXISTS "passkeys_user_id_idx" + ON "passkeys" ("user_id"); + CREATE INDEX IF NOT EXISTS "medication_schedules_medication_id_idx" + ON "medication_schedules" ("medication_id"); + CREATE INDEX IF NOT EXISTS "medication_intake_events_medication_id_idx" + ON "medication_intake_events" ("medication_id"); + CREATE INDEX IF NOT EXISTS "telegram_reminder_messages_schedule_id_idx" + ON "telegram_reminder_messages" ("schedule_id"); + CREATE INDEX IF NOT EXISTS "medication_inventory_items_medication_id_idx" + ON "medication_inventory_items" ("medication_id"); + CREATE INDEX IF NOT EXISTS "medication_side_effects_medication_id_idx" + ON "medication_side_effects" ("medication_id"); + CREATE INDEX IF NOT EXISTS "withings_oauth_states_user_id_idx" + ON "withings_oauth_states" ("user_id"); + CREATE INDEX IF NOT EXISTS "whoop_oauth_states_user_id_idx" + ON "whoop_oauth_states" ("user_id"); + CREATE INDEX IF NOT EXISTS "fitbit_oauth_states_user_id_idx" + ON "fitbit_oauth_states" ("user_id"); + CREATE INDEX IF NOT EXISTS "google_health_oauth_states_user_id_idx" + ON "google_health_oauth_states" ("user_id"); + CREATE INDEX IF NOT EXISTS "whoop_connect_tickets_user_id_idx" + ON "whoop_connect_tickets" ("user_id"); + CREATE INDEX IF NOT EXISTS "cycle_day_logs_cycle_id_idx" + ON "cycle_day_logs" ("cycle_id"); + CREATE INDEX IF NOT EXISTS "invite_tokens_created_by_idx" + ON "invite_tokens" ("created_by"); + CREATE INDEX IF NOT EXISTS "invite_tokens_used_by_idx" + ON "invite_tokens" ("used_by"); + CREATE INDEX IF NOT EXISTS "invite_redemptions_user_id_idx" + ON "invite_redemptions" ("user_id"); + CREATE INDEX IF NOT EXISTS "custom_metric_entries_custom_metric_id_idx" + ON "custom_metric_entries" ("custom_metric_id"); + CREATE INDEX IF NOT EXISTS "encounters_reminder_id_idx" + ON "encounters" ("reminder_id"); + CREATE INDEX IF NOT EXISTS "vaccination_records_encounter_id_idx" + ON "vaccination_records" ("encounter_id"); + CREATE INDEX IF NOT EXISTS "vaccination_records_practitioner_id_idx" + ON "vaccination_records" ("practitioner_id"); + CREATE INDEX IF NOT EXISTS "vaccination_records_reminder_id_idx" + ON "vaccination_records" ("reminder_id"); + CREATE INDEX IF NOT EXISTS "managed_profile_handovers_created_by_id_idx" + ON "managed_profile_handovers" ("created_by_id"); + + -- 2. Indexes that are a prefix of another index on the same table. + DROP INDEX IF EXISTS "biomarkers_user_id_idx"; + DROP INDEX IF EXISTS "clinician_share_link_documents_share_link_id_idx"; + DROP INDEX IF EXISTS "coach_usage_user_id_date_key_idx"; + DROP INDEX IF EXISTS "custom_metrics_user_id_idx"; + DROP INDEX IF EXISTS "cycle_day_logs_user_id_date_idx"; + DROP INDEX IF EXISTS "data_backups_user_id_idx"; + DROP INDEX IF EXISTS "environment_contexts_user_id_date_idx"; + DROP INDEX IF EXISTS "illness_day_logs_episode_id_idx"; + DROP INDEX IF EXISTS "integration_statuses_user_id_idx"; + DROP INDEX IF EXISTS "medications_user_id_idx"; + DROP INDEX IF EXISTS "menstrual_cycles_user_id_start_date_idx"; + DROP INDEX IF EXISTS "mood_entries_user_id_date_idx"; + DROP INDEX IF EXISTS "notification_channels_user_id_idx"; + DROP INDEX IF EXISTS "push_subscriptions_user_id_idx"; + DROP INDEX IF EXISTS "refresh_tokens_user_id_idx"; + DROP INDEX IF EXISTS "user_achievements_user_id_idx"; + DROP INDEX IF EXISTS "user_known_devices_user_id_idx"; +END +$migration$; diff --git a/prisma/migrations/0416_drop_measurements_type_prefix_index/migration.sql b/prisma/migrations/0416_drop_measurements_type_prefix_index/migration.sql new file mode 100644 index 000000000..62339c211 --- /dev/null +++ b/prisma/migrations/0416_drop_measurements_type_prefix_index/migration.sql @@ -0,0 +1,35 @@ +-- Retire `measurements_user_id_type_measured_at_idx`. +-- +-- `(user_id, type, measured_at)` is the leading part of the unique key +-- `(user_id, type, measured_at, source, sleep_stage)`, and live reads have +-- `measurements_live_covering_idx` besides. Every query shape that used the +-- shorter index plans the same way on the unique key, at a cost within a few +-- percent (`tests/integration/index-hygiene.test.ts` compares the plans on a +-- seeded table). The index cost every write to the densest table in the +-- schema and, on a bloated instance, more space than the table itself. +-- +-- This runs at boot, so it must not wait on the table. A plain `DROP INDEX` +-- needs ACCESS EXCLUSIVE on `measurements`, held for the instant the catalog +-- changes; `DROP INDEX CONCURRENTLY` blocks nobody but waits, without bound, +-- for every transaction on the table (a pg_dump, a backfill), long enough for +-- the container to be replaced mid-migration and the next boot to stop at +-- P3009. So the drop is tried once with a two-second lock wait (the most the +-- table's other statements can queue behind the request), and a timeout is +-- swallowed: the migration then succeeds with the index still there, and +-- the nightly measurement maintenance drops it with +-- `DROP INDEX CONCURRENTLY IF EXISTS` (`RETIRED_MEASUREMENT_INDEXES` in +-- `src/lib/jobs/measurement-maintenance.ts`). A fresh database and Prisma's +-- shadow database take the lock at once, so the migration history ends +-- without the index, as the schema does. The pattern, for the next large +-- index: docs/ops/measurement-maintenance.md, "Retired indexes". +-- +-- @retry-on-lock-timeout + +DO $migration$ +BEGIN + PERFORM set_config('lock_timeout', '2s', true); + DROP INDEX IF EXISTS "measurements_user_id_type_measured_at_idx"; +EXCEPTION WHEN lock_not_available THEN + RAISE NOTICE 'measurements_user_id_type_measured_at_idx is in use; the nightly measurement maintenance drops it'; +END +$migration$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 05b4527d1..c38d79ad9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -118,61 +118,61 @@ model User { environmentAqHistoryJson Json? @map("environment_aq_history_json") // Codex OAuth (ChatGPT subscription) - codexAccessTokenEncrypted String? @map("codex_access_token_encrypted") - codexRefreshTokenEncrypted String? @map("codex_refresh_token_encrypted") + codexAccessTokenEncrypted String? @map("codex_access_token_encrypted") + codexRefreshTokenEncrypted String? @map("codex_refresh_token_encrypted") /// @internal: dead mirror of the expiry inside the encrypted Codex creds — /// the refresh path reads the decrypted blob's own `expiresAt` /// (`src/lib/ai/provider.ts`), never this column. Kept (and kept written) /// for wire/backup compatibility rather than dropped. - codexTokenExpiresAt DateTime? @map("codex_token_expires_at") - codexConnectedAt DateTime? @map("codex_connected_at") - codexConnectionStatus String @default("disconnected") @map("codex_connection_status") + codexTokenExpiresAt DateTime? @map("codex_token_expires_at") + codexConnectedAt DateTime? @map("codex_connected_at") + codexConnectionStatus String @default("disconnected") @map("codex_connection_status") // Per-user opt-in to the operator's shared central Codex (ChatGPT // subscription) connection stored on `AppSettings.adminCodex*`. OFF by // default; only takes effect when the operator has actually connected the // central Codex. Routing through it is external egress on a shared, // train-by-default account bound by the operator's rate limits — consent-gated // exactly like any other server-managed egress. - useCentralCodex Boolean @default(false) @map("use_central_codex") - insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") // "aggregated" | "raw" - insightsCachedAt DateTime? @map("insights_cached_at") + useCentralCodex Boolean @default(false) @map("use_central_codex") + insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") // "aggregated" | "raw" + insightsCachedAt DateTime? @map("insights_cached_at") /// @internal: legacy plaintext cache of the AI insights payload, read only /// as a fallback for rows the free-text encryption backfill has not reached /// yet (v1.43.7). Every write since then goes to `insightsCachedTextEncrypted` /// and leaves this NULL; the backfill seals and nulls it on the old rows. /// DROP this column in a later release, once the backfill reports zero /// remaining rows on every instance. - insightsCachedText String? @map("insights_cached_text") + insightsCachedText String? @map("insights_cached_text") /// v1.43.7 — the cached AI insights payload (briefing, per-domain readings, /// recommendations; JSON), AES-256-GCM binary layout under /// `INSIGHTS_CACHED_TEXT_AAD`. Read and written only through /// `src/lib/insights/cached-text-cipher.ts`. - insightsCachedTextEncrypted Bytes? @map("insights_cached_text_encrypted") + insightsCachedTextEncrypted Bytes? @map("insights_cached_text_encrypted") // Language the cached text was generated in. The cache is one slot per // user, so a reader in another language must not be served it: the // snapshot briefing lift and the advisor read treat a tag that differs // from the reader's locale as an empty cache and re-warm in the reader's. // NULL on rows written before the tag existed (treated as matching; the // next write tags it). - insightsCachedLocale String? @map("insights_cached_locale") + insightsCachedLocale String? @map("insights_cached_locale") // v1.16.8 — SHA-256 fingerprint of the compacted feature snapshot the // cached comprehensive text was generated from. Nightly / forced / lazy // regenerations compare the fresh snapshot's hash against this and skip // the provider call (refreshing only `insightsCachedAt`) when nothing // the prompt sees has changed. - insightsSnapshotHash String? @map("insights_snapshot_hash") + insightsSnapshotHash String? @map("insights_snapshot_hash") // v1.16.8 — last time a forced full warm's comprehensive step failed or // timed out. The forced warm path backs off for an hour after a failure // so a persistently broken provider chain cannot be re-driven into a // full generation attempt by every page-open. - insightsWarmFailedAt DateTime? @map("insights_warm_failed_at") + insightsWarmFailedAt DateTime? @map("insights_warm_failed_at") // v1.18.7 — UTC YYYY-MM-DD of the last daily-briefing phrasing re-roll. // The content-hash gate keeps the FINDINGS byte-stable on unchanged data, // but the daily-briefing PARAGRAPH is re-rolled once per calendar day at a // higher, seedless temperature so the prose reads fresh day-over-day // without re-paying for the findings. This stamps the day a re-roll ran so // the gate stays at most one extra call per day. - insightsBriefingRerollDate String? @map("insights_briefing_reroll_date") + insightsBriefingRerollDate String? @map("insights_briefing_reroll_date") // Morning-freshness marker: the user-local YYYY-MM-DD (their profile // timezone) for which the event-driven morning refresh has run — i.e. last // night's sleep has arrived AND the sleep-dependent insight/score cache was @@ -181,13 +181,13 @@ model User { // `morning-digest-refresh` job the sleep-arrival trigger enqueues; the // string form makes the "same local morning" comparison timezone-honest // without a second timestamp read. - morningDigestRefreshedOn String? @map("morning_digest_refreshed_on") + morningDigestRefreshedOn String? @map("morning_digest_refreshed_on") // v1.4.36 W3 T3 — per-user AI Insights exclude-metrics list, mirrors // `coachPrefsJson.excludeMetrics`. The /api/insights/generate route // filters matching keys off `features` before JSON.stringify so the // model never sees data the user opted out of. Default `[]` = legacy // behaviour for every existing row. - insightsExcludeMetrics String[] @default([]) @map("insights_exclude_metrics") + insightsExcludeMetrics String[] @default([]) @map("insights_exclude_metrics") // v1.8.5 — user-level injection-site deny-list. Sites listed here are // never offered for ANY medication's injection-site picker and the @@ -897,11 +897,6 @@ model User { // read serves from this table instead of the unbounded // MoodEntry.findMany walk. moodEntryRollups MoodEntryRollup[] - // v1.4.39 W-MED — per-(user, medication, day) compliance rollup. - // Per-write hook on MedicationIntakeEvent mutations + the scheduler - // keep the row in sync so `/api/medications/intake?scope=compliance` - // and `computeUserHealthScoreFastPath` skip the per-event findMany. - medicationComplianceRollups MedicationComplianceRollup[] // v1.4.40 SB-10 — AI consent receipts the iOS app POSTs when the user // grants AI processing during onboarding. Persisted audit trail // covers App-Store Guideline 5.1.2(i) ("data-collection consent must @@ -1253,7 +1248,6 @@ model EnvironmentContext { updatedAt DateTime @updatedAt @map("updated_at") @@unique([userId, date]) - @@index([userId, date]) // `environment_contexts_aq_gap_idx` (user_id, date) WHERE aq_fetched_at IS // NULL is a partial index from migration 0381 (raw SQL only). @@map("environment_contexts") @@ -1305,6 +1299,8 @@ model Passkey { user User @relation(fields: [userId], references: [id], onDelete: Cascade) + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([userId]) @@map("passkeys") } @@ -1489,7 +1485,6 @@ model UserKnownDevice { // twice for one account, so the "first sighting → alert" decision is a // single unique-constrained upsert. @@unique([userId, deviceHash]) - @@index([userId]) @@map("user_known_devices") } @@ -2093,7 +2088,6 @@ model Measurement { // v1.4.23 — Apple Health batch dedup key. NULL externalId is distinct // (Postgres semantics) so manual entries don't collide here. @@unique([userId, type, source, externalId]) - @@index([userId, type, measuredAt]) // v1.25 — type-less `(userId, measuredAt)` range scan. The bulk feature read // (`extractFeatures`) and consolidation scans walk a user's measurements by // time with no `type` predicate; `(userId, type, measuredAt)` can't serve @@ -2972,10 +2966,6 @@ model Medication { /// roll-up (GI / METABOLIC / INJECTION_SITE / COGNITIVE / /// GLP1_SPECIFIC) and 1-5 Likert severity per entry. sideEffects MedicationSideEffect[] - /// v1.4.39 W-MED — per-(medication, day) compliance rollup. Mutations - /// on the linked MedicationIntakeEvent rows refresh the row inline; - /// the read paths consume this instead of the per-event findMany. - complianceRollups MedicationComplianceRollup[] /// v1.16.3 — archived schedule eras (effective dating). Superseded /// schedule states land here when a wholesale schedule replace changes /// cadence-relevant fields; the band minter segments historical minting @@ -3012,7 +3002,6 @@ model Medication { /// re-mirror of the same concept id returns the existing row (the /// create route pre-queries the triple and catches the P2002 race). @@unique([userId, externalSource, externalId]) - @@index([userId]) @@index([treatmentClass]) @@map("medications") } @@ -3222,6 +3211,8 @@ model MedicationSchedule { medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) telegramMessages TelegramReminderMessage[] + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([medicationId]) @@map("medication_schedules") } @@ -3500,6 +3491,8 @@ model MedicationIntakeEvent { // without this one the purge planned a sequential scan and could not finish // inside statement_timeout. Raw SQL only (partial predicate not expressible // in Prisma). + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([medicationId]) @@map("medication_intake_events") } @@ -3664,6 +3657,8 @@ model MedicationInventoryItem { // `(user_id) WHERE notes IS NOT NULL AND notes_encrypted IS NULL` — see // migration 0243 (v1.28.46 H4) for the med-notes boot-discovery scan. Raw // SQL only (partial predicate not expressible in Prisma). + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([medicationId]) @@map("medication_inventory_items") } @@ -3727,6 +3722,8 @@ model MedicationSideEffect { // `(user_id) WHERE notes IS NOT NULL AND notes_encrypted IS NULL` — see // migration 0243 (v1.28.46 H4) for the med-notes boot-discovery scan. Raw // SQL only (partial predicate not expressible in Prisma). + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([medicationId]) @@map("medication_side_effects") } @@ -3818,6 +3815,8 @@ model TelegramReminderMessage { @@unique([recipientUserId, medicationId, scheduleId, date, phase, timeOfDay], map: "telegram_reminder_messages_recipient_slot_key") @@index([medicationId, date]) + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([scheduleId]) @@map("telegram_reminder_messages") } @@ -4013,6 +4012,9 @@ model InviteToken { redemptions InviteRedemption[] @@index([expiresAt]) + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([createdBy]) + @@index([usedBy]) @@map("invite_tokens") } @@ -4056,6 +4058,8 @@ model ManagedProfileHandover { @@index([profileId]) // `managed_profile_handovers_open_idx` UNIQUE (profile_id) WHERE used_at IS // NULL AND revoked_at IS NULL is a partial index from migration 0383. + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([createdById]) @@map("managed_profile_handovers") } @@ -4109,6 +4113,8 @@ model InviteRedemption { user User? @relation("InviteRedemptions", fields: [userId], references: [id], onDelete: SetNull) @@index([inviteId]) + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([userId]) @@map("invite_redemptions") } @@ -4145,7 +4151,6 @@ model RefreshToken { user User @relation(fields: [userId], references: [id], onDelete: Cascade) - @@index([userId]) /// Composite index — every reuse-detection / device-scoped lookup filters /// on (userId, deviceId), so a single-column device_id index would never /// be used while costing writes. Multiple live rows per (userId, deviceId) @@ -4276,6 +4281,8 @@ model WithingsOAuthState { user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([expiresAt]) + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([userId]) @@map("withings_oauth_states") } @@ -4357,6 +4364,8 @@ model WhoopOAuthState { user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([expiresAt]) + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([userId]) @@map("whoop_oauth_states") } @@ -4389,6 +4398,8 @@ model WhoopConnectTicket { user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([expiresAt]) + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([userId]) @@map("whoop_connect_tickets") } @@ -4443,6 +4454,8 @@ model FitbitOAuthState { user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([expiresAt]) + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([userId]) @@map("fitbit_oauth_states") } @@ -4521,6 +4534,8 @@ model GoogleHealthOAuthState { user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([expiresAt]) + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([userId]) @@map("google_health_oauth_states") } @@ -4651,7 +4666,6 @@ model MoodEntry { // that carry a source-stable id upsert on this; native/MANUAL entries // (NULL externalId) are distinct here and stay on the legacy key above. @@unique([userId, source, externalId]) - @@index([userId, date]) // v1.12.1 (P1-3) — mood-insights reads filter `{ userId, deletedAt: // null, moodLoggedAt: { gte } }` ordered by `moodLoggedAt desc`; this // index turns that into a range scan. See migration 0123. @@ -5038,39 +5052,6 @@ model MoodEntryRollup { @@map("mood_entry_rollups") } -// ─── Medication Compliance Rollup (v1.4.39 W-MED) ───────────── -// -// Per-(userId, medicationId, day) compliance ledger. Replaces the -// unbounded MedicationIntakeEvent findMany on -// `/api/medications/intake?scope=compliance` and the equivalent -// fan-out inside `computeUserHealthScoreFastPath`. -// -// Day-key is anchored to the user's IANA timezone (TEXT, encoded as -// `YYYY-MM-DD`) so the read path can collapse a date range into a -// straight WHERE clause without re-deriving the boundary per row. -// -// Write path: every MedicationIntakeEvent create / update / delete -// (and the scheduler that mints fresh `scheduledFor` rows) fires -// `recomputeMedicationComplianceForDay` which upserts the -// `(user, medication, day)` row. Worker handles weekly / monthly -// re-fold off the hot path. -model MedicationComplianceRollup { - userId String @map("user_id") - medicationId String @map("medication_id") - day String // YYYY-MM-DD anchored to user.timezone - scheduled Int - taken Int - skipped Int - computedAt DateTime @default(now()) @map("computed_at") @db.Timestamptz(3) - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) - - @@id([userId, medicationId, day]) - @@index([userId, day(sort: Desc)]) - @@map("medication_compliance_rollups") -} - // ─── Integration Status (v1.4.15) ───────────────────────── // // Per-(user, integration) row that captures the most recent sync attempt @@ -5140,7 +5121,6 @@ model IntegrationStatus { user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userId, integration]) - @@index([userId]) @@index([state]) @@map("integration_statuses") } @@ -5493,7 +5473,6 @@ model NotificationChannel { preferences NotificationPreference[] @@unique([userId, type]) - @@index([userId]) @@map("notification_channels") } @@ -5521,7 +5500,6 @@ model PushSubscription { user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userId, endpoint]) - @@index([userId]) @@map("push_subscriptions") } @@ -5568,7 +5546,6 @@ model DataBackup { chunks DataBackupChunk[] @@unique([userId, type], map: "data_backups_user_id_type_key") - @@index([userId]) @@map("data_backups") } @@ -6411,7 +6388,6 @@ model Biomarker { mergesInto BiomarkerMerge[] @@unique([userId, name]) - @@index([userId]) @@map("biomarkers") } @@ -6454,7 +6430,6 @@ model CustomMetric { entries CustomMetricEntry[] @@unique([userId, name]) - @@index([userId]) @@map("custom_metrics") } @@ -6492,6 +6467,8 @@ model CustomMetricEntry { // `(user_id) WHERE note IS NOT NULL` — see // migration 0357 for the free-text encryption boot-discovery scan. Raw SQL // only (partial predicate not expressible in Prisma). + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([customMetricId]) @@map("custom_metric_entries") } @@ -6539,7 +6516,6 @@ model UserAchievement { user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userId, achievementId]) - @@index([userId]) @@map("user_achievements") } @@ -7231,7 +7207,6 @@ model CoachUsage { user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userId, dateKey]) - @@index([userId, dateKey]) @@map("coach_usage") } @@ -7482,7 +7457,6 @@ model ClinicianShareLinkDocument { document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade) @@unique([shareLinkId, documentId]) - @@index([shareLinkId]) @@index([documentId]) @@map("clinician_share_link_documents") } @@ -8073,7 +8047,6 @@ model MenstrualCycle { dayLogs CycleDayLog[] @@unique([userId, startDate]) - @@index([userId, startDate]) // Keyset support for the `/api/sync/changes` cycle feed // (`where userId` ordered by `(updatedAt asc, id asc)`). @@index([userId, updatedAt, id]) @@ -8173,9 +8146,10 @@ model CycleDayLog { // NULL-distinct external dedup key for HealthKit re-ingest (the // MoodEntry dual-unique precedent). @@unique([userId, source, externalId]) - @@index([userId, date]) // Keyset support for the `/api/sync/changes` cycle-day feed. @@index([userId, updatedAt, id]) + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([cycleId]) @@map("cycle_day_logs") } @@ -8367,7 +8341,6 @@ model IllnessDayLog { @@unique([episodeId, date]) @@index([userId, date]) - @@index([episodeId]) @@map("illness_day_logs") } @@ -9374,6 +9347,8 @@ model Encounter { @@index([userId, deletedAt, occurredAt]) @@index([userId, status, occurredAt]) @@index([practitionerId]) + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([reminderId]) @@map("encounters") } @@ -9634,6 +9609,10 @@ model VaccinationRecord { @@index([userId, deletedAt, occurredAt]) @@index([userId, antigenSlug, occurredAt]) @@index([customVaccineId]) + // Leads the foreign key, so a cascade from the parent row is an index lookup. + @@index([encounterId]) + @@index([practitionerId]) + @@index([reminderId]) @@map("vaccination_records") } @@ -9866,21 +9845,21 @@ model DocumentAiRun { model LifeEvent { id String @id @default(cuid()) userId String @map("user_id") - /// @pending-consumer(#613) The kind of event, from a deliberately short list. + /// The kind of event, from a deliberately short list. category LifeEventCategory - /// @pending-consumer(#613) First day of the event, `YYYY-MM-DD`. At MONTH or + /// First day of the event, `YYYY-MM-DD`. At MONTH or /// YEAR precision the first day of that month or year. startDate String @map("start_date") - /// @pending-consumer(#613) Last day, `YYYY-MM-DD`, for an event that spans a + /// Last day, `YYYY-MM-DD`, for an event that spans a /// period (parental leave, a year abroad). NULL = a single date. endDate String? @map("end_date") - /// @pending-consumer(#613) How much of the date is known. Applies to both + /// How much of the date is known. Applies to both /// ends. precision LifeEventPrecision - /// @pending-consumer(#613) What happened, in the person's own words, + /// What happened, in the person's own words, /// AES-256-GCM encrypted at rest. titleEncrypted Bytes @map("title_encrypted") - /// @pending-consumer(#613) An optional note, AES-256-GCM encrypted at rest. + /// An optional note, AES-256-GCM encrypted at rest. noteEncrypted Bytes? @map("note_encrypted") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/public/sw.js b/public/sw.js index 4fa12b5ee..4edfd1fee 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.43.7"; + /* @sw-version-fallback */ "v1.43.8"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/scripts/__tests__/migrate-deploy.test.ts b/scripts/__tests__/migrate-deploy.test.ts index a63edc96b..f1e776340 100644 --- a/scripts/__tests__/migrate-deploy.test.ts +++ b/scripts/__tests__/migrate-deploy.test.ts @@ -24,7 +24,9 @@ import { afterEach, describe, expect, it } from "vitest"; import { decideLockRetry, lockTimeoutInReport, + prismaEnv, retryDelays, + utcSessionUrl, } from "../migrate-deploy.mjs"; const P3018_LOCK = `Applying migration \`0412_measurement_hot_updates\` @@ -45,6 +47,11 @@ const P3018_UNIQUE = P3018_LOCK.replace("55P03", "23505").replace( 'duplicate key value violates unique constraint "x"', ); +const P3018_DEADLOCK = P3018_LOCK.replace("55P03", "40P01").replace( + "canceling statement due to lock timeout", + "deadlock detected", +); + const P3009 = `Error: P3009 migrate found failed migrations in the target database, new migrations will not be applied. Read more about how to resolve migration issues in a production database: https://pris.ly/d/migrate-resolve @@ -76,7 +83,11 @@ describe("which failure is retried", () => { failedRows: null, readMigration: reader(MARKED), }), - ).toEqual({ retry: true, migration: "0412_measurement_hot_updates" }); + ).toEqual({ + retry: true, + migration: "0412_measurement_hot_updates", + code: "55P03", + }); }); it("does not retry a lock timeout of an unmarked migration", () => { @@ -91,6 +102,31 @@ describe("which failure is retried", () => { }); }); + it("retries a deadlock of a marked migration, which rolled back whole", () => { + expect(lockTimeoutInReport(P3018_DEADLOCK)).toBe( + "0412_measurement_hot_updates", + ); + expect( + decideLockRetry({ + output: P3018_DEADLOCK, + failedRows: null, + readMigration: reader(MARKED), + }), + ).toEqual({ + retry: true, + migration: "0412_measurement_hot_updates", + code: "40P01", + }); + // Unmarked, a deadlock stays fatal like a lock timeout. + expect( + decideLockRetry({ + output: P3018_DEADLOCK, + failedRows: null, + readMigration: reader(UNMARKED), + }).retry, + ).toBe(false); + }); + it("does not retry any other database error", () => { expect( decideLockRetry({ @@ -121,7 +157,11 @@ describe("which failure is retried", () => { failedRows, readMigration: reader(MARKED), }), - ).toEqual({ retry: true, migration: "0412_measurement_hot_updates" }); + ).toEqual({ + retry: true, + migration: "0412_measurement_hot_updates", + code: "55P03", + }); // A stored failure of another kind stays fatal. expect( decideLockRetry({ @@ -167,6 +207,53 @@ describe("which failure is retried", () => { }); }); +describe("the session time zone Prisma migrates in", () => { + const options = (url: string | undefined) => + new URL(url ?? "").searchParams.get("options"); + + it("pins TimeZone=UTC through the libpq options parameter", () => { + const url = utcSessionUrl( + "postgresql://u:p@db:5432/healthlog?schema=public", + ); + expect(options(url)).toBe("-c TimeZone=UTC"); + expect(new URL(url ?? "").searchParams.get("schema")).toBe("public"); + }); + + it("appends to options the operator set, so a later -c wins", () => { + const url = utcSessionUrl( + "postgresql://u:p@db/h?options=-c%20TimeZone%3DEurope%2FBerlin", + ); + expect(options(url)).toBe("-c TimeZone=Europe/Berlin -c TimeZone=UTC"); + // Running it again adds nothing. + expect(utcSessionUrl(url)).toBe(url); + }); + + it("leaves a URL alone when the startup options are switched off", () => { + const raw = "postgresql://u:p@db/h?schema=public"; + expect(utcSessionUrl(raw, { optionsDisabled: true })).toBe(raw); + expect( + prismaEnv({ DATABASE_URL: raw, DATABASE_SESSION_OPTIONS_DISABLED: "1" }) + .DATABASE_URL, + ).toBe(raw); + }); + + it("passes an unset or unparseable URL through untouched", () => { + expect(utcSessionUrl(undefined)).toBeUndefined(); + expect(utcSessionUrl("")).toBe(""); + expect(utcSessionUrl("not a url")).toBe("not a url"); + expect(prismaEnv({}).DATABASE_URL).toBeUndefined(); + }); + + it("hands Prisma the pinned URL and keeps the rest of the environment", () => { + const env = prismaEnv({ + DATABASE_URL: "postgresql://u:p@db/h", + HEALTHLOG_X: "1", + }); + expect(options(env.DATABASE_URL)).toBe("-c TimeZone=UTC"); + expect(env.HEALTHLOG_X).toBe("1"); + }); +}); + describe("the retry loop", () => { const dirs: string[] = []; afterEach(() => { @@ -246,6 +333,24 @@ process.exit(0); expect(result.stdout).toContain("timed out waiting for a lock (55P03)"); }); + it("does the same for a deadlock, and names it", () => { + const { run, calls } = setup( + [ + [1, P3018_DEADLOCK], + [0, "All migrations have been successfully applied."], + ], + MARKED, + ); + const result = run(); + expect(result.status).toBe(0); + expect(calls()).toEqual([ + "migrate deploy --schema x", + "migrate resolve --rolled-back 0412_measurement_hot_updates --schema x", + "migrate deploy --schema x", + ]); + expect(result.stdout).toContain("was cancelled in a deadlock (40P01)"); + }); + it("gives up after the last delay with Prisma's exit code", () => { const { run, calls } = setup( [ diff --git a/scripts/migrate-demo-edge01.README.md b/scripts/migrate-demo-edge01.README.md index 35dd6cd15..f21a6414d 100644 --- a/scripts/migrate-demo-edge01.README.md +++ b/scripts/migrate-demo-edge01.README.md @@ -107,8 +107,8 @@ acknowledgement). `audit_logs` rows (the text lives in the `details` JSON) instead. - **Transient / regenerable:** idempotency_keys, rate_limits, data_backups, import_jobs, host_metrics, feedback, recommendation_feedback, and all rollup - tables (measurement_rollups, mood_entry_rollups, - medication_compliance_rollups, strain_trimp_cache, cycle_predictions) — these + tables (measurement_rollups, mood_entry_rollups, strain_trimp_cache, + cycle_predictions) — these rebuild on edge01. - **No rows:** workouts / workout_routes / workout_samples (demo has none). - **Instance / global:** app_settings; global catalogues (mood_tags, diff --git a/scripts/migrate-demo-edge01.sh b/scripts/migrate-demo-edge01.sh index af4918879..228e94714 100755 --- a/scripts/migrate-demo-edge01.sh +++ b/scripts/migrate-demo-edge01.sh @@ -65,8 +65,7 @@ # - Ops / transient / regenerable: idempotency_keys, rate_limits, # data_backups, import_jobs, host_metrics, feedback, # recommendation_feedback, all rollup tables (measurement_rollups, -# mood_entry_rollups, medication_compliance_rollups, strain_trimp_cache, -# cycle_predictions) — these REGENERATE on edge01. +# mood_entry_rollups, strain_trimp_cache, cycle_predictions) — these REGENERATE on edge01. # - workouts / workout_routes / workout_samples — demo has none. # - app_settings — instance-level, not user data. # - Global catalogues (mood_tags, cycle_symptoms) — pre-exist on edge01; @@ -299,7 +298,6 @@ DELETE FROM audit_logs WHERE user_id = '$EDGE_DEMO_ID'; -- regenerable / transient tables that may carry FK rows blocking the user delete DELETE FROM measurement_rollups WHERE user_id = '$EDGE_DEMO_ID'; DELETE FROM mood_entry_rollups WHERE user_id = '$EDGE_DEMO_ID'; -DELETE FROM medication_compliance_rollups WHERE user_id = '$EDGE_DEMO_ID'; DELETE FROM strain_trimp_cache WHERE user_id = '$EDGE_DEMO_ID'; DELETE FROM cycle_predictions WHERE user_id = '$EDGE_DEMO_ID'; DELETE FROM measurements WHERE user_id = '$EDGE_DEMO_ID'; diff --git a/scripts/migrate-deploy.mjs b/scripts/migrate-deploy.mjs index 23abb5f9a..d6f9f8973 100644 --- a/scripts/migrate-deploy.mjs +++ b/scripts/migrate-deploy.mjs @@ -21,15 +21,31 @@ * back whole. `src/__tests__/migration-lock-timeout-guard.test.ts` holds the * migrations to that. * - * So: on a 55P03 failure of a migration carrying the marker, mark that one - * migration rolled back and run `migrate deploy` again after a backoff (15, - * 30, 60, 120, 240 seconds). Any other failure, a lock timeout of a migration + * A deadlock (40P01) is the same failure seen from the other side: a marked + * migration's `DO` block holds the locks it took until it commits, and an app + * transaction that took two of the same tables in the other order makes + * Postgres cancel one of the two. When the migration is the one cancelled, + * its block rolled back whole, exactly as on a lock timeout. + * + * So: on a 55P03 or 40P01 failure of a migration carrying the marker, mark + * that one migration rolled back and run `migrate deploy` again after a + * backoff (15, 30, 60, 120, 240 seconds). Any other failure, a lock timeout of a migration * without the marker, or a fifth lock timeout in a row, ends the boot with * Prisma's exit code exactly as before. The failure is read from Prisma's * own report (`Migration name:` and `Database error code:`), or, on a boot * after a failed one (P3009), from the `logs` Prisma stored on the failed * row in `_prisma_migrations`. * + * Every Prisma call runs with its session pinned to UTC, the way the app's + * own connections are (`src/lib/db-session.ts`). Almost every `DateTime` + * column is `timestamp` without a time zone and holds UTC, and Postgres + * converts between that and `timestamptz` / `now()` through the session time + * zone. A migration that mixes the two (0372 does) therefore wrote hours-off + * values on a database whose default zone is not UTC. Prisma ignores `PGTZ`, + * so the pin rides the connection URL as the libpq `options` parameter, which + * Prisma passes to the server. `DATABASE_SESSION_OPTIONS_DISABLED` (a pooler + * that refuses `options`) turns it off, as it does for the app. + * * Environment: * HEALTHLOG_PRISMA_CLI Prisma's CLI entry (default: the image's copy) * HEALTHLOG_MIGRATIONS_DIR where the migration files are (default: @@ -48,19 +64,27 @@ const DEFAULT_RETRY_DELAYS_S = [15, 30, 60, 120, 240]; /** The line a migration carries when a lock-timeout retry is safe for it. */ export const RETRY_MARKER = /^--\s*@retry-on-lock-timeout\s*$/m; -const LOCK_TIMEOUT = "55P03"; +/** Lock timeout and deadlock: both leave a marked migration unapplied. */ +const LOCK_FAILURES = new Set(["55P03", "40P01"]); +const TIME_ZONE_OPTION = "-c TimeZone=UTC"; const MIGRATION_NAME = /^[A-Za-z0-9_]+$/; +/** The SQLSTATE a P3018 report gives, or null. */ +export function errorCodeInReport(text) { + if (typeof text !== "string") return null; + return /^Database error code: (\S+)\s*$/m.exec(text)?.[1] ?? null; +} + /** - * The migration a P3018 report says failed on a lock timeout, or null. - * Prisma prints `Migration name: ` and `Database error code: ` - * for a migration that failed to apply. + * The migration a P3018 report says failed on a lock (a lock timeout or a + * deadlock), or null. Prisma prints `Migration name: ` and + * `Database error code: ` for a migration that failed to apply. */ export function lockTimeoutInReport(text) { if (typeof text !== "string") return null; const name = /^Migration name: (\S+)\s*$/m.exec(text)?.[1]; - const code = /^Database error code: (\S+)\s*$/m.exec(text)?.[1]; - return name && code === LOCK_TIMEOUT ? name : null; + const code = errorCodeInReport(text); + return name && code !== null && LOCK_FAILURES.has(code) ? name : null; } /** Whether Prisma refused to start because a failed migration is on record. */ @@ -78,14 +102,16 @@ function isFailedMigrationOnRecord(output) { * when they were not read * @param {(name: string) => string | null} input.readMigration * the migration's SQL, or null when the image does not ship it - * @returns {{ retry: true, migration: string } | { retry: false, reason: string }} + * @returns {{ retry: true, migration: string, code: string } | { retry: false, reason: string }} */ export function decideLockRetry({ output, failedRows, readMigration }) { let migration = lockTimeoutInReport(output); + let code = migration ? errorCodeInReport(output) : null; if (!migration && isFailedMigrationOnRecord(output) && failedRows) { // More than one failed row is not a state this boot can reason about. if (failedRows.length === 1) { migration = lockTimeoutInReport(failedRows[0].logs); + code = migration ? errorCodeInReport(failedRows[0].logs) : null; } } if (!migration) { @@ -104,7 +130,7 @@ export function decideLockRetry({ output, failedRows, readMigration }) { reason: `${migration} timed out on a lock but is not marked safe to re-run`, }; } - return { retry: true, migration }; + return { retry: true, migration, code }; } /** The backoff schedule, from the environment or the default. */ @@ -116,10 +142,54 @@ export function retryDelays(raw) { : DEFAULT_RETRY_DELAYS_S; } +/** + * `DATABASE_URL` with the session time zone pinned to UTC, for Prisma. + * + * Appends to an `options` value the operator set rather than replacing it; + * a later `-c` wins, so an operator's own `TimeZone` gives way to UTC. A URL + * that does not parse, or a host that disabled the startup options, is + * returned unchanged. + * + * @param {string | undefined} raw + * @param {{ optionsDisabled?: boolean }} [opts] + * @returns {string | undefined} + */ +export function utcSessionUrl(raw, { optionsDisabled = false } = {}) { + if (!raw || optionsDisabled) return raw; + let url; + try { + url = new URL(raw); + } catch { + return raw; + } + const existing = url.searchParams.get("options")?.trim(); + if (existing && existing.endsWith(TIME_ZONE_OPTION)) return raw; + url.searchParams.set( + "options", + existing ? `${existing} ${TIME_ZONE_OPTION}` : TIME_ZONE_OPTION, + ); + return url.toString(); +} + +/** The same switch the app reads in `src/lib/db-session.ts`. */ +function sessionOptionsDisabled(env) { + const raw = env.DATABASE_SESSION_OPTIONS_DISABLED?.trim(); + return raw !== undefined && /^(1|true|yes)$/i.test(raw); +} + +/** The environment every Prisma call runs in. */ +export function prismaEnv(env = process.env) { + const url = utcSessionUrl(env.DATABASE_URL, { + optionsDisabled: sessionOptionsDisabled(env), + }); + return url === undefined ? { ...env } : { ...env, DATABASE_URL: url }; +} + function runPrisma(cli, args) { return new Promise((resolve) => { const child = spawn(process.execPath, [cli, ...args], { stdio: ["ignore", "pipe", "pipe"], + env: prismaEnv(), }); let output = ""; child.stdout.on("data", (chunk) => { @@ -217,9 +287,13 @@ async function main({ return deploy.code; } const delay = delays[attempt]; + const cause = + decision.code === "40P01" + ? "was cancelled in a deadlock (40P01)" + : "timed out waiting for a lock (55P03)"; console.log( - `HealthLog: migration ${decision.migration} timed out waiting for a lock ` + - `(55P03) and changed nothing; marking it rolled back and retrying in ` + + `HealthLog: migration ${decision.migration} ${cause} ` + + `and changed nothing; marking it rolled back and retrying in ` + `${delay}s (retry ${attempt + 1} of ${delays.length}).`, ); const resolve = await runPrisma(cli, [ diff --git a/scripts/repair-intake-anomalies.ts b/scripts/repair-intake-anomalies.ts index 70ef1c500..bc851be5e 100644 --- a/scripts/repair-intake-anomalies.ts +++ b/scripts/repair-intake-anomalies.ts @@ -50,14 +50,9 @@ * CREATE/UPDATE only with `--backfill-eras`. Medications that * already have a revision are skipped (idempotent). * - * After any mutation the affected `(user, medication, day)` compliance - * rollups are recomputed with the SAME slot-level DISTINCT aggregation the - * shared helper runs — see `recomputeComplianceDay` below, a verbatim - * SQL twin of `recomputeMedicationComplianceForDay` in - * `src/lib/rollups/medication-compliance-rollups.ts`. The rollup - * aggregates DISTINCT slots since v1.15.19, so a recompute alone already - * heals the counts; the tombstones additionally clear the phantom rows - * from the history view. + * The tombstones clear the phantom rows from the history view; every + * compliance reader folds rows sharing a slot, so the counts need no + * separate recompute. * * Default is a DRY-RUN that only reports. Idempotent: a second `--fix` * run finds zero groups and changes nothing. @@ -211,8 +206,8 @@ function pickWinner(rows: IntakeRow[]): IntakeRow { // Timezone / day-key helpers — self-contained twins of the app helpers // the previous revision imported. Reference implementations: // - `userDayKey` / `DEFAULT_TIMEZONE`: `src/lib/tz/format.ts` -// - `tzOffsetMinutes` / `startOfDayUtcInTz` / the DST-safe day-after -// boundary: `src/lib/rollups/medication-compliance-rollups.ts` +// - `tzOffsetMinutes` / `startOfDayUtcInTz`: `startOfLocalDayKey` in +// `src/lib/tz/local-day.ts` // ───────────────────────────────────────────────────────────────────── const DEFAULT_TIMEZONE = "Europe/Berlin"; @@ -287,100 +282,6 @@ function startOfDayUtcInTz(dayKey: string, tz: string): Date { return guess; } -/** - * `[start, windowEnd)` UTC window of the local calendar day — the same - * DST-safe boundary derivation `recomputeMedicationComplianceForDay` - * runs (23 / 25-hour days fold correctly because the end anchors on - * the NEXT day's local midnight, not `start + 24 h`). - */ -function dayWindowUtc(dayKey: string, tz: string): { start: Date; end: Date } { - const start = startOfDayUtcInTz(dayKey, tz); - const fallbackEnd = new Date(start.getTime() + 86_400_000); - const [y, m, d] = dayKey.split("-").map(Number); - const probeNext = new Date(Date.UTC(y, m - 1, d, 12, 0, 0) + 86_400_000); - const nextDayKey = dayKeyForScheduledFor(probeNext, tz); - const dstSafeEnd = startOfDayUtcInTz(nextDayKey, tz); - return { - start, - end: dstSafeEnd.getTime() > start.getTime() ? dstSafeEnd : fallbackEnd, - }; -} - -/** - * Recompute one `(userId, medicationId, dayKey)` compliance-rollup row. - * - * VERBATIM SQL TWIN of `recomputeMedicationComplianceForDay` in - * `src/lib/rollups/medication-compliance-rollups.ts` — keep the two in - * sync when the rollup contract changes. v1.15.19 semantics: the inner - * CTE folds rows sharing one `scheduled_for` instant into one DISTINCT - * slot with taken-beats-skipped priority; the outer aggregate counts - * slots, not rows, so a duplicated slot can never inflate `scheduled`. - * The upsert re-aggregates inside the statement (atomic against - * concurrent app writers); the trailing DELETE clears the row when the - * day no longer holds any live event. - */ -async function recomputeComplianceDay( - client: Client, - userId: string, - medicationId: string, - dayKey: string, - tz: string | null | undefined, -): Promise { - const { start, end } = dayWindowUtc(dayKey, safeTimezone(tz)); - await client.query( - ` - WITH slot AS ( - SELECT - BOOL_OR("taken_at" IS NOT NULL AND NOT "skipped") AS slot_taken, - BOOL_OR("skipped") AS slot_skipped - FROM "medication_intake_events" - WHERE "user_id" = $1 - AND "medication_id" = $2 - AND "deleted_at" IS NULL - AND "scheduled_for" >= $4::timestamp - AND "scheduled_for" < $5::timestamp - GROUP BY "scheduled_for" - ), - aggregate AS ( - SELECT - COUNT(*)::int AS scheduled, - COALESCE(SUM(CASE WHEN "slot_taken" THEN 1 ELSE 0 END), 0)::int AS taken, - COALESCE(SUM(CASE WHEN NOT "slot_taken" AND "slot_skipped" THEN 1 ELSE 0 END), 0)::int AS skipped - FROM slot - ) - INSERT INTO "medication_compliance_rollups" - ("user_id", "medication_id", "day", "scheduled", "taken", "skipped", "computed_at") - SELECT $1, $2, $3, aggregate.scheduled, aggregate.taken, aggregate.skipped, NOW() - FROM aggregate - WHERE aggregate.scheduled > 0 - ON CONFLICT ("user_id", "medication_id", "day") DO UPDATE - SET "scheduled" = EXCLUDED."scheduled", - "taken" = EXCLUDED."taken", - "skipped" = EXCLUDED."skipped", - "computed_at" = EXCLUDED."computed_at" - `, - [userId, medicationId, dayKey, utcParam(start), utcParam(end)], - ); - await client.query( - ` - DELETE FROM "medication_compliance_rollups" - WHERE "user_id" = $1 - AND "medication_id" = $2 - AND "day" = $3 - AND NOT EXISTS ( - SELECT 1 - FROM "medication_intake_events" - WHERE "user_id" = $1 - AND "medication_id" = $2 - AND "deleted_at" IS NULL - AND "scheduled_for" >= $4::timestamp - AND "scheduled_for" < $5::timestamp - ) - `, - [userId, medicationId, dayKey, utcParam(start), utcParam(end)], - ); -} - interface Summary { duplicateGroups: number; rowsTombstoned: number; @@ -394,7 +295,6 @@ interface Summary { eraRevisionsCreated: number; startsOnCandidates: number; startsOnAdjusted: number; - rollupsRecomputed: number; } const HHMM_RE = /^([01]\d|2[0-3]):([0-5]\d)$/; @@ -453,7 +353,6 @@ async function main(): Promise { eraRevisionsCreated: 0, startsOnCandidates: 0, startsOnAdjusted: 0, - rollupsRecomputed: 0, }; // Optional per-user scope. The fragment is a fixed string with a @@ -461,7 +360,7 @@ async function main(): Promise { const userFilter = opts.userId ? `AND e."user_id" = $1` : ""; const userParams = opts.userId ? [opts.userId] : []; - /** `userId -> timezone` cache for the rollup day-key derivation. */ + /** `userId -> timezone` cache for the local-day derivations. */ const tzCache = new Map(); async function userTimezone(userId: string): Promise { if (!tzCache.has(userId)) { @@ -474,10 +373,6 @@ async function main(): Promise { return tzCache.get(userId) ?? null; } - // `(userId, medicationId, dayKey)` tuples whose compliance rollup needs - // a recompute after the tombstones. Deduped so a busy day folds once. - const daysToRecompute = new Set(); - try { // ───────────────────────────────────────────────────────────────── // 1. Slot duplicates — exact (user, medication, scheduled_for) @@ -568,12 +463,6 @@ async function main(): Promise { [losers.map((r) => r.id)], ); summary.rowsTombstoned += res.rowCount ?? 0; - - const tz = await userTimezone(group.user_id); - const dayKey = dayKeyForScheduledFor(group.scheduled_for, tz); - daysToRecompute.add( - `${group.user_id}|${group.medication_id}|${dayKey}`, - ); } } @@ -651,12 +540,6 @@ async function main(): Promise { console.log( `${TAG} tombstoned ${res.rowCount ?? 0} implausible row(s) (operator opt-in)`, ); - - for (const row of implausible) { - const tz = await userTimezone(row.user_id); - const dayKey = dayKeyForScheduledFor(row.scheduled_for, tz); - daysToRecompute.add(`${row.user_id}|${row.medication_id}|${dayKey}`); - } } // ───────────────────────────────────────────────────────────────── @@ -822,12 +705,6 @@ async function main(): Promise { [staleRows.map((r) => r.id)], ); summary.staleAnchorTombstoned = res.rowCount ?? 0; - - for (const row of staleRows) { - const tz = await userTimezone(row.user_id); - const dayKey = dayKeyForScheduledFor(row.scheduled_for, tz); - daysToRecompute.add(`${row.user_id}|${row.medication_id}|${dayKey}`); - } } // ───────────────────────────────────────────────────────────────── @@ -1057,35 +934,6 @@ async function main(): Promise { summary.startsOnAdjusted += res.rowCount ?? 0; } } - - // ───────────────────────────────────────────────────────────────── - // 6. Recompute the affected compliance rollups so scheduled/taken - // counts self-correct. Same DISTINCT-slot SQL the shared helper - // runs (see `recomputeComplianceDay` above). - // ───────────────────────────────────────────────────────────────── - if (opts.fix && daysToRecompute.size > 0) { - console.log( - `\n${TAG} 6) recomputing ${daysToRecompute.size} compliance rollup day(s)`, - ); - for (const key of daysToRecompute) { - const [userId, medicationId, dayKey] = key.split("|"); - try { - await recomputeComplianceDay( - client, - userId, - medicationId, - dayKey, - await userTimezone(userId), - ); - summary.rollupsRecomputed += 1; - } catch (err) { - console.error( - `${TAG} rollup recompute failed for ${key}: ` + - `${err instanceof Error ? err.message : String(err)}`, - ); - } - } - } } finally { await client.end(); } @@ -1112,7 +960,6 @@ async function main(): Promise { console.log(` era revisions created: ${summary.eraRevisionsCreated}`); console.log(` startsOn candidates: ${summary.startsOnCandidates}`); console.log(` startsOn adjusted: ${summary.startsOnAdjusted}`); - console.log(` rollup days recomputed: ${summary.rollupsRecomputed}`); } main().catch((err) => { diff --git a/scripts/seed-demo-v15.sql b/scripts/seed-demo-v15.sql index 3fdfe2f7a..a15ca2411 100644 --- a/scripts/seed-demo-v15.sql +++ b/scripts/seed-demo-v15.sql @@ -37,7 +37,6 @@ END $$; -- ── Wipe per-user state ─────────────────────────────────────────── DELETE FROM medication_intake_events WHERE user_id IN (SELECT user_id FROM _demo); -DELETE FROM medication_compliance_rollups WHERE user_id IN (SELECT user_id FROM _demo); DELETE FROM medication_schedules WHERE medication_id IN ( SELECT id FROM medications WHERE user_id IN (SELECT user_id FROM _demo) ); diff --git a/src/__tests__/ai-capability-surface-guard.test.ts b/src/__tests__/ai-capability-surface-guard.test.ts index 74dff38d1..e796fc236 100644 --- a/src/__tests__/ai-capability-surface-guard.test.ts +++ b/src/__tests__/ai-capability-surface-guard.test.ts @@ -56,7 +56,11 @@ const CLIENT_FILES = NON_TEST_FILES.filter((rel) => !rel.startsWith("lib/")); const FEATURE_FLAG_SWEEP = NON_TEST_FILES.filter( (rel) => - !rel.startsWith("lib/feature-flags/") && !rel.startsWith("lib/openapi/"), + !rel.startsWith("lib/feature-flags/") && + !rel.startsWith("lib/openapi/") && + // The deprecation registry names the route to date its retirement; it + // reads nothing from it. + rel !== "lib/http/deprecation.ts", ); const read = (rel: string) => diff --git a/src/__tests__/bearer-scope-enforcement-guard.test.ts b/src/__tests__/bearer-scope-enforcement-guard.test.ts index 5b2562c98..6c0298f4b 100644 --- a/src/__tests__/bearer-scope-enforcement-guard.test.ts +++ b/src/__tests__/bearer-scope-enforcement-guard.test.ts @@ -40,6 +40,177 @@ function filesMatching(re: RegExp): string[] { return sourceFiles().filter((rel) => re.test(read(rel))); } +/** + * The argument text of every call to `callee` in `src`, parens balanced. + * + * The matchers this file used to rely on stopped at the first `)` — + * `requireRecordAuth\([^)]*scope:` read `requireRecordAuth(need(), "x", { + * scope: X })` as having no scope at all — and demanded one spelling of a + * Bearer lookup. A guard whose matcher misses a real call site is green + * because it matched nothing. So calls are read whole: from the `(` after the + * name to its matching `)`, skipping string and template contents, and a + * function DECLARATION of the same name is not a call. + */ +function callArgs(src: string, callee: string): string[] { + const code = stripComments(src); + const out: string[] = []; + const re = new RegExp(`(? { + return callArgs(src, "requireAuth").map((args) => { + const first = topLevelArgs(args)[0]; + return first === undefined || first === "undefined" ? null : first; + }); +} + +/** The scopes the `requireRecordAuth` calls in `src` name in their options. */ +function recordAuthScopes(src: string): string[] { + const out: string[] = []; + for (const args of callArgs(src, "requireRecordAuth")) { + for (const m of args.matchAll(/\bscope\s*:\s*([A-Za-z_$][\w$]*)/g)) { + out.push(m[1]); + } + } + return out; +} + +/** + * Whether `src` turns a Bearer value into an identity: the shared resolver, or + * any `apiToken` lookup (`findUnique`, `findFirst`, `findMany`, their + * `OrThrow` forms, on `prisma` or a transaction) whose arguments name + * `tokenHash`, or raw SQL reading `api_tokens` by `token_hash`. + */ +function resolvesBearer(src: string): boolean { + const code = stripComments(src); + // The shared resolver, and the MCP edge's wrapper around it. The `/mcp` + // route was on the allowlist only because a COMMENT named the resolver; + // with comments stripped it is found by the call it actually makes. + if (/\bresolveBearerToken\b|\bresolveMcpAuthContext\s*\(/.test(code)) { + return true; + } + const lookups = + /\bapiToken\s*\.\s*find(?:Unique|First|Many)(?:OrThrow)?\s*\(/g; + for (const m of code.matchAll(lookups)) { + const args = argsFrom(code, m.index + m[0].length - 1); + if (/\btokenHash\b/.test(args)) return true; + } + // A raw read of the table by hash. An UPDATE that revokes by hash turns + // nothing into an identity and is not one. + return /\bSELECT\b[\s\S]{0,300}?\bFROM\s+"?api_tokens"?[\s\S]{0,300}?\btoken_hash\b/i.test( + code, + ); +} + +describe("the call matchers read whole calls", () => { + it("finds a scope behind nested parentheses and across lines", () => { + const planted = `const ctx = await requireRecordAuth( + needFor(method()), + "measurements", + { scope: MEASUREMENTS_WRITE_SCOPE }, + );`; + expect(recordAuthScopes(planted)).toEqual(["MEASUREMENTS_WRITE_SCOPE"]); + // The old matcher stopped at the first ")" and saw nothing. + expect(/requireRecordAuth\([^)]*\bscope:/.test(planted)).toBe(false); + }); + + it("reads the first requireAuth argument and ignores the options", () => { + expect( + requireAuthScopes( + 'await requireAuth(WORKOUTS_WRITE_SCOPE, { writeBucket: "own" });\n' + + 'await requireAuth(undefined, { writeBucket: "own" });\n' + + "await requireAuth();\n" + + "export async function requireAuth(requiredPermission?: string) {}\n" + + 'await requireAuth("health:write");', + ), + ).toEqual(["WORKOUTS_WRITE_SCOPE", null, null, '"health:write"']); + }); + + it("recognises every spelling of a Bearer lookup", () => { + for (const planted of [ + "prisma.apiToken.findUnique({ where: { tokenHash: h } })", + "prisma.apiToken\n .findUnique({\n where: { userId, tokenHash: hash },\n })", + "tx.apiToken.findFirst({ where: { revokedAt: null, tokenHash: digest(x) } })", + "await prisma.apiToken.findUniqueOrThrow({ where: { tokenHash } })", + "prisma.$queryRaw`SELECT user_id FROM api_tokens WHERE token_hash = ${h}`", + ]) { + expect(resolvesBearer(planted), planted).toBe(true); + } + // A lookup by something else is not a Bearer resolution, even with the + // word elsewhere in the file, and neither is a revocation by hash. + expect( + resolvesBearer( + "prisma.apiToken.findMany({ where: { userId } });\nconst tokenHash = 1;", + ), + ).toBe(false); + expect( + resolvesBearer( + "prisma.$executeRaw`UPDATE api_tokens SET revoked = true WHERE token_hash = ${h}`", + ), + ).toBe(false); + }); +}); + describe("T1 — the Bearer resolution set is frozen", () => { /** * Files allowed to turn a raw `Authorization: Bearer` value into a user. @@ -58,6 +229,13 @@ describe("T1 — the Bearer resolution set is frozen", () => { "lib/mcp/auth.ts", // The `/mcp` transport, via `resolveMcpAuthContext`. "app/mcp/route.ts", + // The local stdio MCP entrypoint, via the same `resolveMcpAuthContext`: a + // pasted token for a power-user's own MCP client, run in a plain Node + // process outside the web server. Same edge, same posture as `/mcp`. It + // was a Bearer resolution this guard never saw, because the matcher only + // knew the resolver's own name and the `/mcp` entry above was matched + // through a comment. + "cli/mcp-stdio.ts", // Hand-rolled Bearer authentication: the external medication-ingest // surface, which gates on BOTH `medication:ingest` and the per-medication // `medication::ingest` grant and never touches `requireAuth`. @@ -90,9 +268,7 @@ describe("T1 — the Bearer resolution set is frozen", () => { // `tokenHash` may sit anywhere inside the `where` object — demanding it // first would let a formatting change (another key ahead of it) walk a // resolver out of the guard's sight. - const resolvers = filesMatching( - /resolveBearerToken|apiToken\s*\.\s*findUnique\(\s*\{[\s\S]*?where:\s*\{[^}]*?tokenHash/, - ); + const resolvers = sourceFiles().filter((rel) => resolvesBearer(read(rel))); // Non-zero proof: an empty match set must fail rather than agree with an // emptied allowlist. @@ -207,18 +383,12 @@ describe("T3 — the mint sites are frozen", () => { }); describe("T4 — declared scopes are exported constants, never string literals", () => { - it("every requireAuth argument is an identifier", () => { + it("every requireAuth scope argument is an identifier", () => { const offenders: string[] = []; for (const rel of sourceFiles()) { - const src = read(rel); - // Call sites only — skip the declaration in `api-handler.ts`, whose - // parameter list is not an argument. - for (const m of src.matchAll( - /(?(); for (const rel of sourceFiles()) { const src = read(rel); - for (const m of src.matchAll( - /(? { // an importer can skip a download. Nothing else: not the list or detail, // not the original or thumbnail, not bulk, not the AI legs. A third route // here is a widening and belongs in review. - const declaring = filesMatching( - /requireAuth\(\s*DOCUMENTS_WRITE_SCOPE\s*\)|requireRecordAuth\([^)]*\bscope:\s*DOCUMENTS_WRITE_SCOPE\b/, - ); + const declaring = sourceFiles().filter((rel) => { + const src = read(rel); + return ( + requireAuthScopes(src).includes("DOCUMENTS_WRITE_SCOPE") || + recordAuthScopes(src).includes("DOCUMENTS_WRITE_SCOPE") + ); + }); expect(declaring).toEqual([ "app/api/documents/inbound/route.ts", "app/api/documents/inbound/source/route.ts", @@ -277,7 +445,7 @@ describe("T4b — `documents:write` opens exactly two doors", () => { // name it. const upload = read("app/api/documents/inbound/route.ts"); expect( - upload.match(/requireAuth\(\s*DOCUMENTS_WRITE_SCOPE\s*\)/g), + requireAuthScopes(upload).filter((x) => x === "DOCUMENTS_WRITE_SCOPE"), ).toHaveLength(1); // And the lookup is a read of one key and nothing else: a single GET. const lookup = read("app/api/documents/inbound/source/route.ts"); @@ -294,13 +462,17 @@ describe("T4c — `workouts:write` opens exactly one door", () => { // The set of files naming the scope IS its reach. The batch ingest and // nothing else: not the workout list or detail, which would hand a // bridge the history it has no business reading back. - const declaring = filesMatching( - /requireAuth\(\s*WORKOUTS_WRITE_SCOPE\s*\)|requireRecordAuth\([^)]*\bscope:\s*WORKOUTS_WRITE_SCOPE\b/, - ); + const declaring = sourceFiles().filter((rel) => { + const src = read(rel); + return ( + requireAuthScopes(src).includes("WORKOUTS_WRITE_SCOPE") || + recordAuthScopes(src).includes("WORKOUTS_WRITE_SCOPE") + ); + }); expect(declaring).toEqual(["app/api/workouts/batch/route.ts"]); const batch = read("app/api/workouts/batch/route.ts"); expect( - batch.match(/requireAuth\(\s*WORKOUTS_WRITE_SCOPE\s*\)/g), + requireAuthScopes(batch).filter((x) => x === "WORKOUTS_WRITE_SCOPE"), ).toHaveLength(1); expect( [...batch.matchAll(/export const (GET|POST|PUT|PATCH|DELETE)\b/g)].map( @@ -330,8 +502,8 @@ describe("T5 — a delegable route naming a Bearer scope is frozen", () => { }; it("only the known routes pass a scope to the record resolver", () => { - const naming = filesMatching( - /requireRecordAuth\([^)]*\bscope:\s*[A-Za-z_$][\w$]*/, + const naming = sourceFiles().filter( + (rel) => recordAuthScopes(read(rel)).length > 0, ); // Non-zero proof: an empty match set must fail rather than agree with an // emptied table. This is the leg most likely to rot — the matcher spans a @@ -343,11 +515,7 @@ describe("T5 — a delegable route naming a Bearer scope is frozen", () => { it("each names the scope this table says it does", () => { for (const [rel, scope] of Object.entries(SCOPED_RECORD_ROUTES)) { - const named = [ - ...read(rel).matchAll( - /requireRecordAuth\([^)]*\bscope:\s*([A-Za-z_$][\w$]*)/g, - ), - ].map((m) => m[1]); + const named = recordAuthScopes(read(rel)); expect(named).toEqual([scope]); } }); diff --git a/src/__tests__/client-fetch-limit-guard.test.ts b/src/__tests__/client-fetch-limit-guard.test.ts index 34d100f06..c9d73875a 100644 --- a/src/__tests__/client-fetch-limit-guard.test.ts +++ b/src/__tests__/client-fetch-limit-guard.test.ts @@ -77,16 +77,10 @@ const ALLOWLIST: readonly Allowed[] = [ "Search-backed picker: the query narrows the whole document vault server-side.", }, { - file: "components/settings/share-document-picker.tsx", + file: "components/documents/document-picker-sheet.tsx", limit: 50, reason: - "Search-backed picker: the query narrows the whole document vault server-side.", - }, - { - file: "components/documents/document-link-picker.tsx", - limit: 50, - reason: - "Search-backed picker: the query narrows the whole document vault server-side.", + "Search-backed picker (episode links and share links): the query narrows the whole document vault server-side.", }, { file: "hooks/use-mood-day.ts", diff --git a/src/__tests__/client-route-zod-guard.test.ts b/src/__tests__/client-route-zod-guard.test.ts new file mode 100644 index 000000000..e50290239 --- /dev/null +++ b/src/__tests__/client-route-zod-guard.test.ts @@ -0,0 +1,149 @@ +/** + * The documents vault and the Coach load no schema library in the browser. + * + * Both routes reached zod through a constant: the list of document kinds, the + * bulk limit, the attachment cap, the Coach data clusters, a conversation + * title length, the ordering of report leaves. Each of those sat in a schema + * module, so importing the number imported the library with it, on two of the + * heaviest client routes (up to 89 KB gzip per route). The constants live in + * zod-free modules now, re-exported by the schema modules for server code, and + * the server parses what the client sends anyway. + * + * What this walks: the static import graph from each route's `page.tsx`, with + * TypeScript's own parser. From the first `"use client"` module on, every + * module it reaches statically ships to the browser; none of them may import + * `zod`. Type-only imports are skipped (they vanish at build). A dynamic + * `import()` is skipped too: it is a separate chunk, loaded when the code + * that asks for it runs (the chart runtime is one), and not part of the + * route's first load. That is the limit of this guard, stated: a lazily + * loaded chunk may still carry zod. + */ +import { describe, expect, it } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, normalize } from "node:path"; +import ts from "typescript"; + +const ROOT = join(__dirname, "..", ".."); + +function resolve(spec: string, from: string): string | null { + let base: string; + if (spec.startsWith("@/")) base = join(ROOT, "src", spec.slice(2)); + else if (spec.startsWith(".")) base = normalize(join(dirname(from), spec)); + else return null; + for (const ext of ["", ".ts", ".tsx", "/index.ts", "/index.tsx"]) { + const candidate = base + ext; + if (/\.tsx?$/.test(candidate) && existsSync(candidate)) return candidate; + } + return null; +} + +/** Value-level static imports and re-exports of one module. */ +function staticImports(source: string, file: string): string[] { + const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, false); + const specs: string[] = []; + for (const node of sf.statements) { + if (ts.isImportDeclaration(node)) { + const clause = node.importClause; + if (clause?.isTypeOnly) continue; + const named = clause?.namedBindings; + if ( + clause && + !clause.name && + named && + ts.isNamedImports(named) && + named.elements.length > 0 && + named.elements.every((e) => e.isTypeOnly) + ) { + continue; + } + specs.push((node.moduleSpecifier as ts.StringLiteral).text); + } else if (ts.isExportDeclaration(node) && node.moduleSpecifier) { + if (node.isTypeOnly) continue; + const clause = node.exportClause; + if ( + clause && + ts.isNamedExports(clause) && + clause.elements.length > 0 && + clause.elements.every((e) => e.isTypeOnly) + ) { + continue; + } + specs.push((node.moduleSpecifier as ts.StringLiteral).text); + } + } + return specs; +} + +function isClient(source: string): boolean { + return /^\s*["']use client["']/.test(source); +} + +/** Client modules reached from `entry` that import zod, with one path each. */ +function clientZodImports(entry: string): { file: string; path: string[] }[] { + const seen = new Map(); + const parent = new Map(); + const stack: [string, boolean][] = [[join(ROOT, entry), false]]; + const hits: string[] = []; + let walked = 0; + while (stack.length > 0) { + const [file, inClient] = stack.pop()!; + const prior = seen.get(file); + if (prior === true || (prior === false && !inClient)) continue; + const source = readFileSync(file, "utf8"); + const client = inClient || isClient(source); + seen.set(file, client); + walked += 1; + for (const spec of staticImports(source, file)) { + if (client && (spec === "zod" || spec.startsWith("zod/"))) { + hits.push(file); + } + const next = resolve(spec, file); + if (!next) continue; + if (!parent.has(next)) parent.set(next, file); + stack.push([next, client]); + } + } + if (walked < 20) + throw new Error(`walked only ${walked} modules from ${entry}`); + return [...new Set(hits)].map((file) => { + const path: string[] = []; + let cursor: string | undefined = file; + while (cursor && path.length < 10) { + path.push(cursor.slice(ROOT.length + 1)); + cursor = parent.get(cursor); + } + return { file: file.slice(ROOT.length + 1), path }; + }); +} + +describe("client routes without the schema library", () => { + for (const route of [ + "src/app/documents/page.tsx", + "src/app/coach/page.tsx", + "src/app/coach/conversations/page.tsx", + ]) { + it(`${route} ships no zod in its static client graph`, () => { + const hits = clientZodImports(route); + expect( + hits.map((h) => h.path.join(" <- ")), + "Move the constant the client needs into a zod-free module (see src/lib/documents/inbound-constants.ts) and re-export it from the schema module.", + ).toEqual([]); + }); + } + + it("sees a zod import where one is", () => { + // The schema module is zod by definition; walked as a client entry it + // must be reported, or the walk above proves nothing. + const probe = join(ROOT, "src/lib/validations/inbound-documents.ts"); + const source = readFileSync(probe, "utf8"); + expect(staticImports(source, probe).some((s) => s.startsWith("zod"))).toBe( + true, + ); + expect( + staticImports( + 'import type { A } from "zod";\nimport { type B } from "zod";\nimport { c } from "zod/v4";\nexport { d } from "./x";\nexport type { E } from "./y";', + "probe.ts", + ), + ).toEqual(["zod/v4", "./x"]); + }); +}); diff --git a/src/__tests__/empty-state-ratchet.test.ts b/src/__tests__/empty-state-ratchet.test.ts new file mode 100644 index 000000000..7b95208ab --- /dev/null +++ b/src/__tests__/empty-state-ratchet.test.ts @@ -0,0 +1,125 @@ +/** + * An honest no-data state goes through `EmptyState` (or `ChartEmptyState` on + * a chart body), UI standards §6. + * + * Seventy-five surfaces said "nothing here yet" in a hand-written muted + * paragraph, in five sizes and three alignments, some centred in a padded + * box, some a bare line under the header. This is a ratchet, not a ban: the + * hand-written copies that remain are listed by file and key, a new one + * fails the run, and a site that moves over leaves the list. The ones that + * stay are not list or section empty states but a field that has no value + * ("not recorded" in a profile row), where a centred block would be the + * wrong shape. A count would let a new copy in wherever an old one left. + * + * Matched: a `

`, `

` or `` whose class list carries + * `text-muted-foreground` and whose only child is `{t("")}` with a last + * key segment that names an empty state (`empty`, `none`, `noData`, + * `noResults`, `notRecorded`, `no…Yet`, `nothing…`; a `…Hint` key is a + * hint, not a state). + * + * Not matched (stated, not hidden): a message assembled from several nodes, + * a key built at runtime, a state without the muted colour. The guard holds + * the idiom, not every way to say nothing. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(__dirname, ".."); + +/** + * The hand-written empty states that remain, each a field-level line rather + * than a list or section state. Remove an entry when its site moves to + * `EmptyState`; never add one. + */ +const REMAINING = [ + "components/cycle/cycle-phase-crosstab.tsx: cycle.insights.headline.empty", + "components/documents/episode-documents-card.tsx: documents.episodeCard.emptyLine", + "components/illness/episode-visits-card.tsx: encounters.episodeCard.emptyLine", + "components/insights/workout-detail/day-context.tsx: insights.workouts.detail.dayMoodNone", + "components/insights/workout-detail/day-context.tsx: insights.workouts.detail.daySleepNone", + "components/labs/biomarker-aliases.tsx: labs.aliases.empty", + "components/medications/dose-history-ledger.tsx: medications.detail.verlauf.summaryEmpty", + "components/medications/scheduling/dose-window-editor.tsx: medications.detail.zeitplan.window.noTimes", + "components/medications/sections/composition-section.tsx: medications.composition.editorEmpty", + "components/records/screening-hints-card.tsx: records.life.screening.noReminder", + "components/settings/ai/fallback-chain-card.tsx: settings.ai.providerChain.addNoneAvailable", + "components/settings/integrations/apple-health-card.tsx: settings.appleHealth.delivery.noSyncYet", + "components/settings/report-selection/scope-summary.tsx: reportSelection.scopeNone", + "components/timeline/selection-bar.tsx: timeline.selection.empty", +]; + +const ELEMENT_RE = + /<(p|div|span)\b([^>]*?)>\s*\{t\(\s*"([a-zA-Z0-9_.]+)"[^)]*\)\}\s*<\/\1>/g; +const EMPTY_KEY_RE = + /(?:^|\.)[A-Za-z0-9]*?(?:empty|Empty|none|None|noData|NoData|noResults|NoResults|notRecorded|NotRecorded|noSeries|NoSeries|noReminder|NoReminder|noMedications|noSymptoms|noTimes|nothing|Nothing)[A-Za-z0-9]*$|(?:^|\.)no[A-Z][A-Za-z0-9]*Yet$/; + +function findHandWrittenEmptyStates( + files: { path: string; source: string }[], +): string[] { + const hits: string[] = []; + for (const { path, source } of files) { + for (const match of source.matchAll(ELEMENT_RE)) { + const [, , attrs, key] = match; + if (!attrs.includes("text-muted-foreground")) continue; + if (!EMPTY_KEY_RE.test(key) || key.endsWith("Hint")) continue; + hits.push(`${path}: ${key}`); + } + } + return hits; +} + +describe("empty states go through EmptyState (ratchet)", () => { + const files = walkSourceFiles(SRC, { floor: 2500 }) + .filter( + (p) => + p.endsWith(".tsx") && + (p.startsWith("components/") || p.startsWith("app/")) && + !p.startsWith("components/ui/") && + !p.includes("__tests__/"), + ) + .map((path) => ({ path, source: readFileSync(join(SRC, path), "utf8") })); + + it("walks the UI tree", () => { + expect(files.length).toBeGreaterThan(800); + }); + + it("adds no hand-written empty state, and lists none that moved over", () => { + const hits = findHandWrittenEmptyStates(files).sort(); + expect( + hits, + 'A new hand-written empty state: render it through (or ChartEmptyState on a chart body). A site that moved over: remove its entry.', + ).toEqual([...REMAINING].sort()); + }); + + it("recognises the idiom it exists to count", () => { + expect( + findHandWrittenEmptyStates([ + { + path: "components/a.tsx", + source: + '

{t("admin.aiQuality.empty")}

', + }, + { + path: "components/b.tsx", + source: + '

{t("cycle.sheet.emptyHint")}

', + }, + { + path: "components/c.tsx", + source: '', + }, + { + path: "components/d.tsx", + source: + '

{t("insights.noAnalysisYet")}

', + }, + ]), + ).toEqual([ + "components/a.tsx: admin.aiQuality.empty", + "components/d.tsx: insights.noAnalysisYet", + ]); + }); +}); diff --git a/src/__tests__/helpers/metric-unit-display.ts b/src/__tests__/helpers/metric-unit-display.ts new file mode 100644 index 000000000..0b3971e0e --- /dev/null +++ b/src/__tests__/helpers/metric-unit-display.ts @@ -0,0 +1,24 @@ +/** + * A `useUnitDisplay` result for the metric preference, for static renders + * that have no QueryClient (the real hook hangs off `useAuth`). Metric is the + * identity on values and yields the canonical display symbols. + */ +import * as dt from "@/lib/measurements/display-transform"; +import type { UnitDisplay } from "@/hooks/use-unit-display"; + +export function metricUnitDisplay( + glucoseUnit: UnitDisplay["glucoseUnit"] = "mg/dL", +): UnitDisplay { + const forType = (t: string) => dt.getDisplayTransform(t, "metric"); + return { + preference: "metric", + glucoseUnit, + transformFor: forType, + toDisplay: (t, v) => dt.applyDisplayTransform(v, forType(t)), + fromDisplay: (t, v) => dt.invertDisplayTransform(v, forType(t)), + toDisplayDelta: (t, v) => dt.applyDisplayTransformDelta(v, forType(t)), + unitFor: (t) => forType(t).displayUnit, + decimalsFor: (t) => forType(t).decimals, + isTransformed: (t) => dt.hasDisplayTransform(t), + }; +} diff --git a/src/__tests__/helpers/prisma-schema-names.ts b/src/__tests__/helpers/prisma-schema-names.ts index fd7b5b448..b9e187e12 100644 --- a/src/__tests__/helpers/prisma-schema-names.ts +++ b/src/__tests__/helpers/prisma-schema-names.ts @@ -41,6 +41,20 @@ export interface DeclaredField { physicalName: string; } +/** The owning side of a relation: the field that carries `@relation(fields:)`. */ +export interface DeclaredRelation { + /** The relation field's name. */ + name: string; + /** The model it points at. */ + target: string; + /** Whether the relation field is optional (`Type?`). */ + optional: boolean; + /** The scalar fields holding the key, by their datamodel names. */ + fields: string[]; + /** The declared `onDelete`, or null when Prisma's default applies. */ + onDelete: string | null; +} + export interface DeclaredModel { name: string; mappedName: string | null; @@ -48,6 +62,8 @@ export interface DeclaredModel { physicalName: string; /** Scalar + enum fields only — relation fields carry no column. */ fields: DeclaredField[]; + /** Relations this model owns the key of. */ + relations: DeclaredRelation[]; } export interface DeclaredSchema { @@ -58,7 +74,9 @@ export interface DeclaredSchema { const BLOCK_MAP = /^\s*@@map\("([^"]+)"\)/m; const FIELD_MAP = /@map\("([^"]+)"\)/; /** `name Type[]? @attr…` — the two leading tokens are what matters. */ -const FIELD_LINE = /^\s*(\w+)\s+(\w+)(?:\[\])?\??\s*(.*)$/; +const FIELD_LINE = /^\s*(\w+)\s+(\w+)(?:\[\])?(\??)\s*(.*)$/; +const RELATION_FIELDS = /@relation\([^)]*\bfields:\s*\[([^\]]*)\]/; +const ON_DELETE = /@relation\([^)]*\bonDelete:\s*(\w+)/; /** Drop `//` and `///` lines so prose can never read as a declaration. */ function stripComments(block: string): string { @@ -112,15 +130,31 @@ export function readDeclaredSchema( const models = rawModels.map(({ name, body }) => { const mappedName = BLOCK_MAP.exec(body)?.[1] ?? null; const fields: DeclaredField[] = []; + const relations: DeclaredRelation[] = []; for (const line of body.split("\n")) { if (/^\s*@@/.test(line)) continue; const match = FIELD_LINE.exec(line); if (!match) continue; - const [, fieldName, baseType, rest] = match; + const [, fieldName, baseType, optional, rest] = match; // A field whose type is another model is a relation, not a column. The // back-reference side carries no `@relation` attribute at all, so the // type is what decides it — keying on `@relation` would miss half. - if (modelNames.has(baseType)) continue; + if (modelNames.has(baseType)) { + const keys = RELATION_FIELDS.exec(rest)?.[1]; + if (keys !== undefined) { + relations.push({ + name: fieldName, + target: baseType, + optional: optional === "?", + fields: keys + .split(",") + .map((key) => key.trim()) + .filter(Boolean), + onDelete: ON_DELETE.exec(rest)?.[1] ?? null, + }); + } + continue; + } const fieldMapped = FIELD_MAP.exec(rest)?.[1] ?? null; fields.push({ name: fieldName, @@ -128,7 +162,13 @@ export function readDeclaredSchema( physicalName: fieldMapped ?? fieldName, }); } - return { name, mappedName, physicalName: mappedName ?? name, fields }; + return { + name, + mappedName, + physicalName: mappedName ?? name, + fields, + relations, + }; }); return { enums, models }; diff --git a/src/__tests__/helpers/unit-display-mock.ts b/src/__tests__/helpers/unit-display-mock.ts index 60a90a914..3d95f3460 100644 --- a/src/__tests__/helpers/unit-display-mock.ts +++ b/src/__tests__/helpers/unit-display-mock.ts @@ -1,8 +1,9 @@ /** * A real `UnitDisplay` for one fixed preference, for component tests that * render without a QueryClient (`useUnitDisplay` reads the account through - * react-query). Built on the production transforms, so a test asserts the - * same numbers and symbols the hook would give. + * react-query). Built on the production transforms and unit labels, so a + * test asserts the same numbers and symbols the hook would give (labels in + * English, the bundle the component tests render in). * * vi.mock("@/hooks/use-unit-display", async () => { * const { unitDisplayFor } = await import("@/__tests__/helpers/unit-display-mock"); @@ -19,6 +20,22 @@ import { invertDisplayTransform, type UnitPreference, } from "@/lib/measurements/display-transform"; +import { measurementUnitLabel } from "@/lib/measurements/unit-label"; + +import en from "../../../messages/en.json"; + +function english(key: string): string { + const leaf = key + .split(".") + .reduce( + (node, part) => + node && typeof node === "object" + ? (node as Record)[part] + : undefined, + en, + ); + return typeof leaf === "string" ? leaf : key; +} export function unitDisplayFor( preference: UnitPreference, @@ -35,7 +52,8 @@ export function unitDisplayFor( fromDisplay: (type, value) => invertDisplayTransform(value, forType(type)), toDisplayDelta: (type, value) => applyDisplayTransformDelta(value, forType(type)), - unitFor: (type) => forType(type).displayUnit, + unitFor: (type) => + measurementUnitLabel(type, forType(type).displayUnit, english), decimalsFor: (type) => forType(type).decimals, isTransformed: (type) => hasDisplayTransform(type), }; diff --git a/src/__tests__/inline-action-guard.test.ts b/src/__tests__/inline-action-guard.test.ts new file mode 100644 index 000000000..82b6fccba --- /dev/null +++ b/src/__tests__/inline-action-guard.test.ts @@ -0,0 +1,121 @@ +/** + * A small text action inside a line of content goes through `InlineAction` + * (`src/components/ui/inline-action.tsx`). + * + * Thirty-odd hand-rolled copies of the same "underlined small text link" + * carried three different focus rings, none at all on half of them, and a + * 16 px touch target on a phone. The primitive owns the 44 px floor on a + * coarse pointer and the ring; this guard keeps a new copy from appearing + * beside it. + * + * Matched: a `', + }, + { + path: "components/z.tsx", + source: 'Go', + }, + ]; + expect(findHandRolledInlineActions(planted)).toEqual([ + "components/x.tsx:Link", + "components/y.tsx:button", + ]); + }); + + it("is used where the copies were", () => { + const users = files.filter((f) => f.source.includes(" + /^\[[\s\S]*\{\s*id\s*:\s*[^}]+\}\s*,?\s*\]$/.test(text.trim()); + if (order.startsWith("[") || order.startsWith("{")) return literal(order); + const decl = new RegExp( + `\\b(?:const|let)\\s+${order}\\b[^=]*=\\s*(\\[[\\s\\S]*?\\]);`, + ).exec(file); + return decl ? literal(decl[1]) : false; +} + +/** + * A `distinct` read returns one row per key, so ordering by that key alone is + * already total: `distinct: ["analyte"]` with `orderBy: { analyte: "asc" }`. + */ +function ordersByDistinctKey(body: string, order: string): boolean { + const distinct = /\bdistinct\s*:\s*\[\s*["'](\w+)["']\s*\]/.exec(body); + if (!distinct) return false; + return new RegExp(`^\\{\\s*${distinct[1]}\\s*:[^,}]+\\}$`).test(order.trim()); +} + +describe("capped lists go through listPage", () => { + const files = walkSourceFiles(SRC, { floor: 3000 }).filter( + (file) => + !file.startsWith("generated/") && + !file.includes("__tests__/") && + !/\.test\.tsx?$/.test(file), + ); + const read = (file: string) => + stripComments(readFileSync(join(SRC, file), "utf8")); + const routes = files.filter((f) => /^app\/api\/.+\/route\.tsx?$/.test(f)); + + it("the matchers fire on planted sources", () => { + const handRolled = ` + const found = await prisma.allergy.findMany({ take: overfetch(limit) }); + const { rows, cap } = capRows(found, limit); + return apiListSuccess(rows, cap);`; + expect(/\bcapRows\s*\(/.test(handRolled)).toBe(true); + expect(/\blistPage\s*\(/.test(handRolled)).toBe(false); + + expect( + /\bhasMore\s*:[^,\n]*\.length\b/.test( + "hasMore: rows.length === q.limit,", + ), + ).toBe(true); + expect( + /\bhasMore\s*:[^,\n]*\.length\b/.test("hasMore: cap.truncated,"), + ).toBe(false); + + const noTiebreak = `listPage(limit, (take) => + prisma.x.findMany({ orderBy: { createdAt: "desc" }, take }))`; + const [body] = callBodies(noTiebreak, "listPage"); + expect(endsInId(orderByValue(body)!, noTiebreak)).toBe(false); + + const tiebreak = `listPage(limit, (take) => + prisma.x.findMany({ orderBy: [{ createdAt: "desc" }, { id: "desc" }], take }))`; + const [good] = callBodies(tiebreak, "listPage"); + expect(endsInId(orderByValue(good)!, tiebreak)).toBe(true); + + // A shorthand `orderBy` resolves through its declaration. + const viaVariable = `const orderBy: X[] = [primaryOrder, { id: order }]; + listPage(limit, (take) => prisma.x.findMany({ where, orderBy, take }))`; + const [short] = callBodies(viaVariable, "listPage"); + expect(endsInId(orderByValue(short)!, viaVariable)).toBe(true); + // A distinct read is ordered whole by its own key. + const distinctRead = `listPage(limit, (take) => + prisma.x.findMany({ distinct: ["analyte"], orderBy: { analyte: "asc" }, take }))`; + const [dist] = callBodies(distinctRead, "listPage"); + expect(ordersByDistinctKey(dist, orderByValue(dist)!)).toBe(true); + expect(ordersByDistinctKey(dist, '{ createdAt: "desc" }')).toBe(false); + const viaBadVariable = `const orderBy = [primaryOrder]; + listPage(limit, (take) => prisma.x.findMany({ where, orderBy, take }))`; + const [bad] = callBodies(viaBadVariable, "listPage"); + expect(endsInId(orderByValue(bad)!, viaBadVariable)).toBe(false); + }); + + it("finds the capped routes", () => { + const capped = routes.filter((f) => /\bapiListSuccess\s*\(/.test(read(f))); + expect(capped.length).toBeGreaterThanOrEqual(15); + }); + + it("every route answering a capped list gets its cap from listPage", () => { + const unbound = routes.filter((f) => { + const code = read(f); + return ( + /\bapiListSuccess\s*\(/.test(code) && !/\blistPage\s*\(/.test(code) + ); + }); + expect(unbound).toEqual([]); + }); + + it("nothing cuts a list by hand outside the list modules", () => { + const handRolled = files.filter( + (f) => + !LIST_MODULES.has(f) && /\b(?:capRows|overfetch)\s*\(/.test(read(f)), + ); + expect(handRolled).toEqual([]); + }); + + it("no hasMore is a length comparison", () => { + const guessed = routes.filter((f) => + /\bhasMore\s*:[^,\n]*\.length\b/.test(read(f)), + ); + expect(guessed).toEqual([]); + }); + + it("every listPage or capped read orders by something that ends in the row id", () => { + const unordered: string[] = []; + let reads = 0; + for (const f of files) { + if (LIST_MODULES.has(f)) continue; + const code = read(f); + for (const body of [ + ...callBodies(code, "listPage"), + ...callBodies(code, "capped"), + ]) { + reads += 1; + const order = orderByValue(body); + if ( + !order || + !(endsInId(order, code) || ordersByDistinctKey(body, order)) + ) { + unordered.push(f); + } + } + } + expect(reads).toBeGreaterThanOrEqual(18); + expect(unordered).toEqual([]); + }); +}); diff --git a/src/__tests__/migration-lock-timeout-guard.test.ts b/src/__tests__/migration-lock-timeout-guard.test.ts index 6afe05a6e..919e7661f 100644 --- a/src/__tests__/migration-lock-timeout-guard.test.ts +++ b/src/__tests__/migration-lock-timeout-guard.test.ts @@ -23,6 +23,8 @@ import { readdirSync, readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { RETIRED_MEASUREMENT_INDEXES } from "@/lib/jobs/measurement-maintenance"; + import { RETRY_MARKER } from "../../scripts/migrate-deploy.mjs"; const MIGRATIONS_DIR = join(process.cwd(), "prisma", "migrations"); @@ -89,6 +91,109 @@ function violations(name: string, sql: string): string[] { return found; } +/** + * `CREATE INDEX CONCURRENTLY` / `DROP INDEX CONCURRENTLY` refuse to run in a + * transaction block, and under `migrate deploy` they do so whenever the file + * holds any other statement (SQLSTATE 25001, seen against Prisma 7 and + * Postgres 16). Such a statement therefore stands alone in its file. + */ +function concurrentViolations(name: string, sql: string): string[] { + const statements = topLevelStatements(sql); + const concurrent = statements.filter((statement) => + /\bCONCURRENTLY\b/i.test(statement), + ); + if (concurrent.length === 0 || statements.length === 1) return []; + return [ + `${name}: a CONCURRENTLY statement must be the only statement in its file`, + ]; +} + +describe("concurrent index statements", () => { + it("stand alone in their migration file", () => { + const found = migrations().flatMap(({ name, sql }) => + concurrentViolations(name, sql), + ); + expect(found, found.join("\n")).toEqual([]); + }); + + it("reads the statements there are", () => { + // No migration holds a concurrent statement since 0416 stopped dropping + // concurrently; the rule stands for the next one. A reader that stopped + // splitting statements would see none and pass, so it must see many. + const statements = migrations().flatMap(({ sql }) => + topLevelStatements(sql), + ); + expect(statements.length).toBeGreaterThan(1000); + }); + + it("refuses a concurrent statement beside another one", () => { + expect( + concurrentViolations( + "a", + 'DROP INDEX CONCURRENTLY IF EXISTS "x";\nDROP INDEX CONCURRENTLY IF EXISTS "y";', + ), + ).toHaveLength(1); + expect( + concurrentViolations( + "b", + '-- CONCURRENTLY in a comment\nDROP INDEX CONCURRENTLY IF EXISTS "x";', + ), + ).toEqual([]); + }); +}); + +/** + * `DROP INDEX CONCURRENTLY` blocks no reader or writer, but it waits without + * bound for every transaction on the table, and in a migration that wait is + * the boot's: a pg_dump or a backfill outlasts the container's start period, + * the half-applied migration is left on record, and the next boot stops at + * P3009. An index is retired instead by a bounded `DROP INDEX` that tolerates + * the timeout, finished by the nightly measurement maintenance (0416). + */ +function concurrentDropViolations(name: string, sql: string): string[] { + return topLevelStatements(sql).some((statement) => + /^DROP INDEX CONCURRENTLY\b/i.test(statement), + ) + ? [`${name}: DROP INDEX CONCURRENTLY waits on the table at boot`] + : []; +} + +describe("retired indexes", () => { + it("are never dropped concurrently in the boot path", () => { + const found = migrations().flatMap(({ name, sql }) => + concurrentDropViolations(name, sql), + ); + expect(found, found.join("\n")).toEqual([]); + }); + + it("are each dropped by a migration that bounds its wait and tolerates the timeout", () => { + expect(RETIRED_MEASUREMENT_INDEXES.length).toBeGreaterThan(0); + for (const index of RETIRED_MEASUREMENT_INDEXES) { + const owner = migrations().find(({ sql }) => + new RegExp(`DROP INDEX IF EXISTS "${index}"`).test(sql), + ); + expect(owner, index).toBeDefined(); + expect(owner?.sql).toMatch(/set_config\(\s*'lock_timeout'/); + expect(owner?.sql).toMatch(/EXCEPTION WHEN lock_not_available THEN/); + } + }); + + it("counter-check: refuses a concurrent drop, not one in a comment", () => { + expect( + concurrentDropViolations( + "a", + 'DROP INDEX CONCURRENTLY IF EXISTS "measurements_x_idx";', + ), + ).toHaveLength(1); + expect( + concurrentDropViolations( + "b", + '-- DROP INDEX CONCURRENTLY "x";\nDROP INDEX IF EXISTS "x";', + ), + ).toEqual([]); + }); +}); + describe("migration lock waits", () => { it("bounds lock waits where they take effect and marks only repeatable files", () => { const found = migrations().flatMap(({ name, sql }) => @@ -97,11 +202,14 @@ describe("migration lock waits", () => { expect(found, found.join("\n")).toEqual([]); }); - it("sees the one migration that bounds its wait today", () => { + it("sees the migrations that bound their wait", () => { const marked = migrations() .filter(({ sql }) => RETRY_MARKER.test(sql)) .map(({ name }) => name); expect(marked).toContain("0412_measurement_hot_updates"); + expect(marked).toContain("0414_drop_medication_compliance_rollups"); + expect(marked).toContain("0415_foreign_key_and_prefix_indexes"); + expect(marked).toContain("0416_drop_measurements_type_prefix_index"); }); it("catches each shape it exists to refuse", () => { diff --git a/src/__tests__/page-header-h1-guard.test.ts b/src/__tests__/page-header-h1-guard.test.ts new file mode 100644 index 000000000..b9fd15c05 --- /dev/null +++ b/src/__tests__/page-header-h1-guard.test.ts @@ -0,0 +1,84 @@ +/** + * A module page's title is a `PageHeader` (UI standards §1, §5): the H1 on + * one tier (`text-2xl font-bold tracking-tight`), its explainer muted under + * it, its actions beside it. + * + * Four in-app headings were hand-rolled beside it — the insights sub-page + * shell (`text-xl sm:text-2xl font-semibold`), the medication detail, the + * check-in and its result (`font-semibold`) — and each read a step lighter + * than every other page title. They go through `PageHeader` now; this guard + * keeps a new `

` literal from appearing outside the places where a + * different heading is the point. + * + * Matched: an ` = { + "components/ui/page-header.tsx": "the primitive", + // Hero identity surfaces, not page titles. + "components/insights/hero-strip.tsx": "insights hero greeting", + "components/insights/coach-panel/coach-hero.tsx": "Coach hero", + "components/insights/coach-panel/coach-conversation.tsx": + "screen-reader title while the hero is folded away", + // The first-run flow: full-screen steps with their own focus management. + "components/onboarding/step-heading.tsx": "first-run step heading", + // Outside the app shell: sign-in, register, claim, invite, the share-link + // gate, the key-mismatch gate. + "app/auth/login/page.tsx": "sign-in card", + "app/auth/register/page.tsx": "register card", + "components/auth/claim-profile.tsx": "claim card", + "app/invite/[token]/page.tsx": "invalid invite screen", + "components/clinician/share-unlock-gate.tsx": "share-link unlock gate", + "app/key-mismatch-page.tsx": "key-mismatch gate", + // Error and not-found screens. + "app/not-found.tsx": "not-found screen", + "app/c/[token]/not-found.tsx": "share-link not-found screen", + "app/c/error.tsx": "share-link error screen", + "app/global-error.tsx": "renders without the stylesheet", + "components/error-details.tsx": "error boundary", + // Prose pages (the prose H1 tier, §5). + "app/about/page.tsx": "prose page", + "app/privacy/page.tsx": "prose page", +}; + +describe("page titles go through PageHeader", () => { + const files = walkSourceFiles(SRC, { floor: 2500 }).filter( + (p) => + p.endsWith(".tsx") && + (p.startsWith("components/") || p.startsWith("app/")) && + !p.includes("__tests__/"), + ); + const hasH1 = (source: string) => /]/.test(stripComments(source)); + const withH1 = files.filter((p) => hasH1(readFileSync(join(SRC, p), "utf8"))); + + it("finds the H1 sites at all", () => { + expect(withH1.length).toBeGreaterThan(10); + }); + + it("has no hand-rolled H1 outside the allowlist", () => { + expect(withH1.filter((p) => !(p in ALLOWED))).toEqual([]); + }); + + it("counter-check: an H1 in code counts, one in a comment does not", () => { + expect(hasH1(`return

Title

;`)).toBe(true); + expect(hasH1(`// the page's

lives in PageHeader\nreturn null;`)).toBe( + false, + ); + expect(hasH1(`/* an

above */ return

x

;`)).toBe(false); + }); + + it("keeps every allowlist entry live", () => { + const set = new Set(withH1); + for (const key of Object.keys(ALLOWED)) + expect(set.has(key), key).toBe(true); + }); +}); diff --git a/src/__tests__/read-over-post-guard.test.ts b/src/__tests__/read-over-post-guard.test.ts index c699a9ad4..d118df810 100644 --- a/src/__tests__/read-over-post-guard.test.ts +++ b/src/__tests__/read-over-post-guard.test.ts @@ -2,8 +2,10 @@ * `readOverPost` lets a delegable route answer a POST at the grant's read * level. That widens what a read-only grant reaches, so the set of routes * declaring it is frozen here: adding one is a reviewed diff, not an - * accident. Today it is the record search alone, whose query is health data - * that must stay out of URLs. + * accident. Today it is the record search, whose query is health data that + * must stay out of URLs, and the document assist, whose POST writes nothing + * (under `requireAuth` the declaration only keeps it off the shared write + * ceiling). * * The matcher tolerates whitespace and line breaks, and the guard asserts it * matched something, so a rewrite that hides the declaration from the regex @@ -16,7 +18,13 @@ import { join, relative, sep } from "node:path"; const ROOT = process.cwd(); -const FROZEN = ["src/app/api/search/route.ts"]; +const FROZEN = [ + // The draft-only document assist: a POST that writes nothing. Under + // `requireAuth` the declaration only keeps it out of the shared write + // ceiling; there is no grant for it to widen. + "src/app/api/documents/inbound/[id]/suggest/route.ts", + "src/app/api/search/route.ts", +]; describe("readOverPost declarations", () => { it("are exactly the frozen set", () => { diff --git a/src/__tests__/record-write-rate-limit-contract.test.ts b/src/__tests__/record-write-rate-limit-contract.test.ts index 3330c46c4..73a775e93 100644 --- a/src/__tests__/record-write-rate-limit-contract.test.ts +++ b/src/__tests__/record-write-rate-limit-contract.test.ts @@ -4,9 +4,11 @@ * The batch endpoints have been capped at 60 calls a minute since they were * written; the per-record creates a naive or older client loops over were not * capped at all, which is backwards — the batch endpoint is the one a - * well-behaved client uses. Eleven of them share one generous per-account - * bucket now, and the operations publish its name and its cap so a client that - * meets the ceiling can read which one it met. + * well-behaved client uses. Every authenticated write now shares one generous + * per-account bucket, charged where the caller is resolved + * (`src/lib/auth/write-meter.ts`), and the operations listed below publish + * its name and its cap so a client that meets the ceiling can read which one + * it met. * * A published number is only worth reading if it cannot drift from the code, so * this pulls the bucket key and the limit out of `prisma.$queryRaw` — the same @@ -15,9 +17,9 @@ * would prove nothing: the sentence is written by hand, deliberately, and this * is what keeps the hand-written half honest. * - * The route half is structural: every route in the list below calls the shared - * helper. Its limit is that it reads the file rather than the running handler, - * so a call placed after an early return would still count as present. + * The route half is structural: every route in the list below resolves its + * caller through a metering resolver and does not opt out. Its limit is that + * it reads the file rather than the running handler. */ import { readFileSync } from "node:fs"; import { join } from "node:path"; @@ -32,6 +34,7 @@ import { } from "@/lib/rate-limit"; import { buildOpenApiDocument } from "@/lib/openapi/registry"; import { recordWriteRateLimitResponse } from "@/lib/openapi/routes/shared"; +import { RECORD_WRITE_RATE_LIMITED_CODE } from "@/lib/auth/write-meter"; vi.mock("@/lib/db", () => ({ prisma: { $queryRaw: vi.fn() } })); @@ -121,24 +124,27 @@ describe("the shared single-record write bucket", () => { expect(published).toContain("Retry-After"); }); - it("is called by every route that publishes it", () => { - const missing = ON_THE_SHARED_BUCKET.filter(([route]) => { + it("is charged on the path every route that publishes it takes", () => { + // The bucket is charged by the identity resolvers (`meterAccountWrite`), + // not by each route, so a listed route meets it by resolving its caller + // through one of them and not opting out. `write-rate-limit-guard` + // holds every other mutating route to the same rule. + const unmetered = ON_THE_SHARED_BUCKET.filter(([route]) => { const src = readFileSync(join(process.cwd(), route), "utf8"); - return !src.includes("checkRecordWriteRateLimit("); + return ( + !/\brequire(?:Record)?Auth\s*\(/.test(src) || + /\bwriteBucket\s*:\s*["']own["']/.test(src) + ); }).map(([route]) => route); - expect(missing).toEqual([]); + expect(unmetered).toEqual([]); }); - it("names the code it publishes at every site", () => { - // The prose is not the discriminator: two of these routes consult a second - // bucket of their own, and a client that can only read `error` cannot tell - // which one refused it. - const silent = ON_THE_SHARED_BUCKET.filter(([route]) => { - const src = readFileSync(join(process.cwd(), route), "utf8"); - return !src.includes('errorCode: "record_write.rate_limited"'); - }).map(([route]) => route); - expect(silent).toEqual([]); - expect(published).toContain("record_write.rate_limited"); + it("names the code it publishes", () => { + // The prose is not the discriminator: several of these routes consult a + // second bucket of their own, and a client that can only read `error` + // cannot tell which one refused it. + expect(RECORD_WRITE_RATE_LIMITED_CODE).toBe("record_write.rate_limited"); + expect(published).toContain(RECORD_WRITE_RATE_LIMITED_CODE); }); it("is published on every operation that answers it", () => { diff --git a/src/__tests__/score-pill-guard.test.ts b/src/__tests__/score-pill-guard.test.ts new file mode 100644 index 000000000..e77314137 --- /dev/null +++ b/src/__tests__/score-pill-guard.test.ts @@ -0,0 +1,98 @@ +/** + * A grade chip goes through `ScorePill` (`src/components/ui/score-pill.tsx`). + * + * Eleven chips that state a grade beside a reading (confidence, score gain, + * band, lab range) were hand-rolled in two looks: a /15 wash without border + * in the crosstabs, a /10 wash with a /40 border on the correlation and trend + * cards, and the middle confidence step was blue on one and amber on the + * other. This guard keeps a new copy of the chip from appearing beside the + * primitive. + * + * Matched: one string literal that combines the pill shape (`rounded-full`, + * a small padding `px-1.5|2|2.5`, `text-xs|2xs`) with a semantic tone wash + * (`bg-success|info|warning|destructive/10|15`). A neutral `bg-muted` chip is + * `TagChip`'s business, not this guard's. + * + * Not matched: a tone assembled at runtime from a map whose entries live in + * another literal. The guard catches the idiom, not every way to paint one. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(__dirname, ".."); + +const ALLOWED: Record = { + // A link to the release notes styled as a call to action, not a grade. + "components/admin/version-tile-section.tsx": + "update-available link, not a grade", +}; + +const LITERAL_RE = /["'`]([^"'`\n]*\brounded-full\b[^"'`\n]*)["'`]/g; + +function isGradeChip(cls: string): boolean { + return ( + /(?:^|\s)px-(?:1\.5|2|2\.5)(?:\s|$)/.test(cls) && + /(?:^|\s)text-(?:xs|2xs)(?:\s|$)/.test(cls) && + /(?:^|\s)bg-(?:success|info|warning|destructive)\/1[05](?:\s|$)/.test(cls) + ); +} + +function findGradeChips(files: { path: string; source: string }[]): string[] { + const hits = new Set(); + for (const { path, source } of files) { + for (const match of source.matchAll(LITERAL_RE)) { + if (isGradeChip(match[1])) hits.add(path); + } + } + return [...hits].sort(); +} + +describe("grade chips go through ScorePill", () => { + const files = walkSourceFiles(SRC, { floor: 2500 }) + .filter( + (p) => + (p.startsWith("components/") || p.startsWith("app/")) && + !p.startsWith("components/ui/") && + !p.includes("__tests__/"), + ) + .map((path) => ({ path, source: readFileSync(join(SRC, path), "utf8") })); + + it("walks the UI tree", () => { + expect(files.length).toBeGreaterThan(1000); + }); + + it("has no hand-rolled grade chip outside the allowlist", () => { + expect(findGradeChips(files).filter((p) => !(p in ALLOWED))).toEqual([]); + }); + + it("keeps every allowlist entry live", () => { + const hits = new Set(findGradeChips(files)); + for (const key of Object.keys(ALLOWED)) + expect(hits.has(key), key).toBe(true); + }); + + it("recognises the idiom it exists to refuse", () => { + expect( + findGradeChips([ + { + path: "components/a.tsx", + source: + 'const C = { high: "bg-success/15 text-success" }; ', + }, + { + path: "components/b.tsx", + source: + '', + }, + ]), + ).toEqual(["components/a.tsx"]); + }); + + it("is used where the copies were", () => { + const users = files.filter((f) => f.source.includes(" = { hits: 1, why: "computed_at is timestamptz", }, - "src/lib/rollups/medication-compliance-rollups.ts": { - hits: 1, - why: "computed_at is timestamptz", - }, "src/lib/jobs/job-failures.ts": { hits: 2, why: "pg-boss tables are timestamptz", diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 8b5a6feb1..a41563ed7 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -164,7 +164,6 @@ const PINNED_AFFORDANCES: Record< // behind the delete toast's Undo action. "toast.success": 3, }, - "src/components/cycle/cycle-phase-crosstab.tsx": { "text-success": 1 }, // v1.36.x — two call sites, one decision: the intake toast routes through // `intakeToastOptions()`, which returns `undefined` in the caller's own // record, and sonner's one-argument form is what the unit tests assert. So @@ -214,25 +213,10 @@ const PINNED_AFFORDANCES: Record< "src/components/insights/coach-panel/suggested-action-card.tsx": { "text-success": 1, }, - "src/components/insights/confidence-badge.ts": { "text-success": 1 }, "src/components/insights/daily-briefing.tsx": { "text-success": 1 }, "src/components/insights/derived/band-tokens.ts": { "text-success": 1 }, - // The green `+N` chip beside the Health Score label. It reports a week that - // went up, not an action that succeeded, and it wears the same band token - // the score's own number does — there is no outcome here to route through - // the outcome module. - "src/components/insights/health-score-card.tsx": { "text-success": 1 }, "src/components/insights/insights-edit-mode.tsx": { "toast.success": 2 }, "src/components/insights/insights-tab-strip.tsx": { "toast.success": 1 }, - "src/components/insights/mood/mood-better-days.tsx": { "text-success": 1 }, - "src/components/insights/mood/mood-factor-metric-crosstab.tsx": { - "text-success": 1, - }, - "src/components/insights/mood/mood-tag-influence.tsx": { "text-success": 1 }, - "src/components/insights/mood/mood-tag-metric-crosstab.tsx": { - "text-success": 1, - }, - "src/components/insights/personal-record-badge.tsx": { "text-success": 1 }, "src/components/labs/biomarker-form.tsx": { "toast.success": 1 }, "src/components/labs/biomarker-manager.tsx": { "toast.success": 2 }, "src/components/labs/lab-biomarker-detail.tsx": { "toast.success": 1 }, @@ -369,10 +353,6 @@ const PINNED_AFFORDANCES: Record< "src/components/settings/insights-pill-order-section.tsx": { "toast.success": 1, }, - "src/components/settings/integration-status-pill.tsx": { - CheckCircle2: 2, - "text-success": 1, - }, "src/components/settings/integrations/connections-panel.tsx": { "toast.success": 2, }, @@ -430,6 +410,11 @@ const PINNED_AFFORDANCES: Record< "src/components/settings/webhook-card.tsx": { "text-success": 1 }, "src/components/targets/target-edit-sheet.tsx": { "toast.success": 2 }, "src/components/ui/password-strength.tsx": { "text-success": 2 }, + // The success tone of the grade chip (a high confidence, a score that went + // up). It states a grade beside a reading, never that a write succeeded; + // the crosstab, confidence and score chips that each carried their own copy + // of this class now render through it. + "src/components/ui/score-pill.tsx": { "text-success": 1 }, }; /** diff --git a/src/__tests__/tz-day-paths-harness.test.ts b/src/__tests__/tz-day-paths-harness.test.ts index 7adf653da..598ea1108 100644 --- a/src/__tests__/tz-day-paths-harness.test.ts +++ b/src/__tests__/tz-day-paths-harness.test.ts @@ -85,7 +85,7 @@ import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; import { serialiseDocument } from "@/lib/documents/store"; import { resolveIntakeActionability } from "@/lib/medications/intake-actionable"; import { moodDateKey } from "@/lib/mood/date-key"; -import { dayKeyForScheduledFor } from "@/lib/rollups/medication-compliance-rollups"; +import { dayKeyForScheduledFor } from "@/lib/medications/dose-day-refresh"; import { dateOnlyAtNoonUtc, dayKeyAsUtcMidnight, diff --git a/src/__tests__/unit-preference-display-guard.test.ts b/src/__tests__/unit-preference-display-guard.test.ts index 4f6362757..b5febe503 100644 --- a/src/__tests__/unit-preference-display-guard.test.ts +++ b/src/__tests__/unit-preference-display-guard.test.ts @@ -328,8 +328,14 @@ const UNIT_LITERAL_ALLOWLIST: Record< reason: "a display-kind tag; the weight row converts through the hook", }, "components/insights/mood/mood-factor-metric-crosstab.tsx": { - lines: 2, - reason: "a display-kind tag; the weight row converts through the hook", + lines: 1, + reason: + "a display-kind tag; the weight row converts through crosstabReading", + }, + "components/insights/metric-crosstab.tsx": { + lines: 3, + reason: + "the boards' display kinds and the storage token each is in; rows convert through getReadingTransform and label through measurementUnitLabel", }, // ── Canonical by contract ── "lib/insights/metric-status-registry.ts": { diff --git a/src/__tests__/write-rate-limit-guard.test.ts b/src/__tests__/write-rate-limit-guard.test.ts new file mode 100644 index 000000000..3b3b3559b --- /dev/null +++ b/src/__tests__/write-rate-limit-guard.test.ts @@ -0,0 +1,226 @@ +/** + * Structural guard: every mutating route meets a rate limit. + * + * 169 of 364 mutating handlers met no limiter at all: every PATCH and DELETE + * on an `[id]`, and most single-record creates. The shared per-account write + * bucket now sits on the identity resolvers (`meterAccountWrite`, + * `src/lib/auth/write-meter.ts`), so a route that resolves its caller through + * one of them is metered without knowing it. This guard is what keeps that + * true for the routes added next year: + * + * - a route file exporting POST, PUT, PATCH or DELETE must either resolve + * its caller through a metering resolver, call a limiter of its own, or + * stand in {@link UNMETERED} with the reason; + * - a route that opts out of the shared bucket (`writeBucket: "own"`) must + * stand in {@link OWN_BUCKET} with the reason AND call its own limiter, so + * the opt-out can never mean "no limit"; + * - a helper that limits on a route's behalf is accepted only while its own + * source calls a limiter. + * + * The routes are derived from the tree, never listed by hand. The check is + * per file, not per handler: a file whose GET resolves through a metering + * resolver while its POST does not would pass. Comments are stripped first; + * the planted cases at the top prove the matchers fire. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +/** The resolvers in `api-handler.ts` that charge the shared write bucket. */ +const METERING_RESOLVERS = [ + "requireAuth", + "requireRecordAuth", + "requireActorAuth", + "requireGuardianAuth", + "requireAdmin", + "requireCookieAuth", + "requireFreshMfa", + "requireFreshMfaIfEnrolled", + "requireFreshMfaOrElevationIfEnrolled", + "requireRecentProof", + "requireBearerAuth", + "requireMfaManagementAuth", +]; + +/** Helpers that call a limiter on the route's behalf, and where they live. */ +const LIMITING_HELPERS: Record = { + claimGate: ["lib/managed-profiles/claim-gate.ts"], + applyWebhookRateLimit: [ + "lib/withings/webhook-handler.ts", + "lib/whoop/webhook-handler.ts", + ], +}; + +/** Mutating routes with no limit, each with the reason it needs none. */ +const UNMETERED: Record = { + "app/api/auth/logout/route.ts": + "Ends the caller's own session or token and nothing else. It is idempotent, writes no record data, and refusing it would keep alive a credential its holder asked to end.", +}; + +/** Routes metered by a dedicated bucket instead of the shared one. */ +const OWN_BUCKET: Record = { + "app/api/measurements/batch/route.ts": + "The HealthKit uploader sends up to 60 batches of 500 a minute, and an outbox replay of parked pages bypasses the client pacer; the route's own 60-a-minute batch bucket meters it.", + "app/api/workouts/batch/route.ts": + "The workout importer sends page after page during a catch-up pass; the route's own batch bucket meters it.", + "app/api/medications/intake/bulk/route.ts": + "The Apple Health dose import and the standalone adoption send 200 to 500 intakes per request in sequence; the route's own bulk bucket meters it.", + "app/api/mood-entries/bulk/route.ts": + "The standalone adoption sends 200 entries per request in sequence; the route's own bulk bucket meters it.", + "app/api/cycle/day-logs/bulk/route.ts": + "The cycle HealthKit sweep sends 500-day slices in sequence; the route's own bulk bucket meters it.", + "app/api/nutrients/batch/route.ts": + "The nutrient sync sends 500-row batches in sequence; the route's own batch bucket meters it.", + "app/api/insights/chat/route.ts": + "A Coach turn is metered by the Coach's own buckets: 20 turns a minute (`coach-chat`) and one turn slot at a time.", + "app/api/insights/chat/fenced/route.ts": + "A fenced document chat turn is metered by its own per-account bucket, sized for model turns.", + "app/api/documents/inbound/[id]/chat/route.ts": + "A document chat turn is metered by its own per-account bucket, sized for model turns.", + "app/api/insights/ecg/route.ts": + "The ECG sweep sends one recording per request with no client-side delay, newest first, over the whole HealthKit history; the route's own 600-a-minute ingest bucket meters it.", +}; + +function stripComments(text: string): string { + return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); +} + +const WRITES = + /export\s+(?:const|async\s+function)\s+(POST|PUT|PATCH|DELETE)\b/; +const OWN_LIMITER = /\b(?:check\w*RateLimit|checkRateLimit)\s*\(/; +const OPTS_OUT = /\bwriteBucket\s*:\s*["']own["']/; + +function callsAny(code: string, names: readonly string[]): boolean { + return names.some((name) => new RegExp(`\\b${name}\\s*\\(`).test(code)); +} + +function limited(code: string): boolean { + return ( + OWN_LIMITER.test(code) || callsAny(code, Object.keys(LIMITING_HELPERS)) + ); +} + +describe("every mutating route meets a rate limit", () => { + const files = walkSourceFiles(SRC, { floor: 3000 }).filter( + (file) => !file.startsWith("generated/") && !file.includes("__tests__/"), + ); + const read = (file: string) => + stripComments(readFileSync(join(SRC, file), "utf8")); + const mutating = files + .filter((f) => /^app\/.+\/route\.tsx?$/.test(f)) + .filter((f) => WRITES.test(read(f))); + + it("the matchers fire on planted sources", () => { + expect(WRITES.test("export const PATCH = apiHandler(async () => {")).toBe( + true, + ); + expect(WRITES.test("export async function DELETE(req) {")).toBe(true); + expect(WRITES.test("export const GET = apiHandler(async () => {")).toBe( + false, + ); + expect( + callsAny( + 'const { user } = await requireRecordAuth("write", "labs");', + METERING_RESOLVERS, + ), + ).toBe(true); + // A resolver named in a comment or an import does not count. + expect( + callsAny( + stripComments( + "import { requireAuth } from 'x';\n// requireAuth() here", + ), + METERING_RESOLVERS, + ), + ).toBe(false); + expect(OPTS_OUT.test('requireAuth(SCOPE, { writeBucket: "own" })')).toBe( + true, + ); + expect(limited("const rl = await checkRateLimit(`k:${id}`, 5, 60)")).toBe( + true, + ); + expect(limited("const rl = rateLimitHeaders(x)")).toBe(false); + }); + + it("finds the mutating routes", () => { + expect(mutating.length).toBeGreaterThanOrEqual(300); + }); + + it("meters every mutating route, or names why not", () => { + const unlimited = mutating.filter((f) => { + if (f in UNMETERED) return false; + const code = read(f); + const metered = + callsAny(code, METERING_RESOLVERS) && !OPTS_OUT.test(code); + return !metered && !limited(code); + }); + expect(unlimited).toEqual([]); + }); + + it("holds every opt-out to the reviewed list, and each to its own limiter", () => { + const optedOut = mutating.filter((f) => OPTS_OUT.test(read(f))).sort(); + expect(optedOut).toEqual(Object.keys(OWN_BUCKET).sort()); + const withoutOwn = optedOut.filter((f) => !OWN_LIMITER.test(read(f))); + expect(withoutOwn).toEqual([]); + }); + + it("accepts a limiting helper only while it calls a limiter", () => { + for (const [helper, homes] of Object.entries(LIMITING_HELPERS)) { + for (const home of homes) { + const code = read(home); + expect(code, home).toMatch( + new RegExp(`export\\s+async\\s+function\\s+${helper}\\b`), + ); + expect(OWN_LIMITER.test(code), home).toBe(true); + } + } + }); + + it("keeps the exceptions to routes that exist and still need them", () => { + for (const route of Object.keys(UNMETERED)) { + expect(mutating).toContain(route); + const code = read(route); + expect(callsAny(code, METERING_RESOLVERS) || limited(code), route).toBe( + false, + ); + } + }); + + it("charges the bucket in each metering resolver", () => { + const handler = read("lib/api-handler.ts"); + for (const name of [ + "requireAuth", + "requireActorAuth", + "requireRecordAuth", + "requireGuardianAuth", + "requireAdmin", + "requireCookieAuth", + "requireFreshMfa", + "requireBearerAuth", + ]) { + const start = handler.search( + new RegExp(`export\\s+async\\s+function\\s+${name}\\s*\\(`), + ); + expect(start, name).toBeGreaterThanOrEqual(0); + const next = handler.indexOf("\nexport ", start + 1); + const body = handler.slice(start, next === -1 ? undefined : next); + expect(body, name).toMatch(/\bmeterAccountWrite\s*\(/); + } + // The three that only delegate reach the meter through `requireAuth`. + for (const name of [ + "requireFreshMfaIfEnrolled", + "requireFreshMfaOrElevationIfEnrolled", + "requireRecentProof", + ]) { + const start = handler.search( + new RegExp(`export\\s+async\\s+function\\s+${name}\\s*\\(`), + ); + const next = handler.indexOf("\nexport ", start + 1); + expect(handler.slice(start, next), name).toMatch(/\brequireAuth\s*\(/); + } + }); +}); diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx index b9716739b..55009852b 100644 --- a/src/app/achievements/page.tsx +++ b/src/app/achievements/page.tsx @@ -43,6 +43,7 @@ import { type AchievementCategory, type AchievementProgress, } from "@/lib/gamification/achievements"; +import { TrendTile } from "@/components/ui/trend-tile"; const iconMap: Record = { Activity, @@ -351,7 +352,7 @@ export default function AchievementsPage() { />
-
+

{t("achievements.points")}

@@ -370,8 +371,8 @@ export default function AchievementsPage() { className="h-1.5" aria-label={t("achievements.points")} /> -
-
+ +

{t("achievements.unlocked")}

@@ -384,8 +385,8 @@ export default function AchievementsPage() {

{t("achievements.remainingUnlocks", { count: remainingUnlocks })}

-
-
+ +

{t("achievements.nextGoal")}

@@ -428,7 +429,7 @@ export default function AchievementsPage() { className="mt-3 h-1.5" aria-label={t("achievements.nextProgressLabel")} /> -
+
{grouped.length === 0 ? ( diff --git a/src/app/api/account/activity/route.ts b/src/app/api/account/activity/route.ts index 365584600..bade35a9c 100644 --- a/src/app/api/account/activity/route.ts +++ b/src/app/api/account/activity/route.ts @@ -40,7 +40,8 @@ import { prisma } from "@/lib/db"; import { getAuditLogRetentionDays } from "@/lib/jobs/audit-log-cleanup"; import { annotate } from "@/lib/logging/context"; import { GRANT_PARTY_SELECT } from "@/lib/sharing/grant-view"; -import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap"; +import { apiListSuccess } from "@/lib/api/list-cap"; +import { listPage } from "@/lib/http/list-query"; export const dynamic = "force-dynamic"; @@ -99,31 +100,31 @@ export const GET = apiHandler(async () => { const retentionDays = getAuditLogRetentionDays(); const windowStart = new Date(Date.now() - retentionDays * 86_400_000); - const found = await prisma.auditLog.findMany({ - where: { - userId: user.id, - actorUserId: { not: null }, - // A switch into one's own record is not a delegation and has nothing to - // report. `actingActorFor` already refuses to stamp one, so this is the - // second end of that rule rather than its only enforcement. - NOT: { actorUserId: user.id }, - // The window the copy promises. The purge job makes it true eventually; - // this makes it true now, which matters on an instance whose retention - // was shortened after rows were already written. - createdAt: { gte: windowStart }, - }, - orderBy: { createdAt: "desc" }, - take: overfetch(MAX_ROWS), - select: { - id: true, - actorUserId: true, - action: true, - details: true, - createdAt: true, - }, - }); - - const { rows, cap } = capRows(found, MAX_ROWS); + const { rows, cap } = await listPage(MAX_ROWS, (take) => + prisma.auditLog.findMany({ + where: { + userId: user.id, + actorUserId: { not: null }, + // A switch into one's own record is not a delegation and has nothing to + // report. `actingActorFor` already refuses to stamp one, so this is the + // second end of that rule rather than its only enforcement. + NOT: { actorUserId: user.id }, + // The window the copy promises. The purge job makes it true eventually; + // this makes it true now, which matters on an instance whose retention + // was shortened after rows were already written. + createdAt: { gte: windowStart }, + }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take, + select: { + id: true, + actorUserId: true, + action: true, + details: true, + createdAt: true, + }, + }), + ); const actorIds = [ ...new Set(rows.map((r) => r.actorUserId).filter((id) => id !== null)), diff --git a/src/app/api/account/grants/route.ts b/src/app/api/account/grants/route.ts index 2851c662e..74663d2e0 100644 --- a/src/app/api/account/grants/route.ts +++ b/src/app/api/account/grants/route.ts @@ -73,7 +73,8 @@ import { } from "@/lib/sharing/grant-view"; import { GrantError, inviteGrant } from "@/lib/sharing/grants"; import { inviteGrantSchema } from "@/lib/validations/account-sharing"; -import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap"; +import { apiListSuccess } from "@/lib/api/list-cap"; +import { combinedCap, listPage } from "@/lib/http/list-query"; /** * Ten invitations an hour, per account. @@ -92,28 +93,25 @@ export const GET = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "sharing.grants.list" } }); - const [givenFound, receivedFound] = await Promise.all([ - prisma.accountGrant.findMany({ - where: { grantorId: user.id }, - orderBy: { createdAt: "desc" }, - take: overfetch(MAX_GRANTS_PER_DIRECTION), - include: { grantee: { select: GRANT_PARTY_SELECT } }, - }), - prisma.accountGrant.findMany({ - where: { granteeId: user.id }, - orderBy: { createdAt: "desc" }, - take: overfetch(MAX_GRANTS_PER_DIRECTION), - include: { grantor: { select: GRANT_PARTY_SELECT } }, - }), - ]); - const { rows: given, cap: givenCap } = capRows( - givenFound, - MAX_GRANTS_PER_DIRECTION, - ); - const { rows: received, cap: receivedCap } = capRows( - receivedFound, - MAX_GRANTS_PER_DIRECTION, - ); + const [{ rows: given, cap: givenCap }, { rows: received, cap: receivedCap }] = + await Promise.all([ + listPage(MAX_GRANTS_PER_DIRECTION, (take) => + prisma.accountGrant.findMany({ + where: { grantorId: user.id }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take, + include: { grantee: { select: GRANT_PARTY_SELECT } }, + }), + ), + listPage(MAX_GRANTS_PER_DIRECTION, (take) => + prisma.accountGrant.findMany({ + where: { granteeId: user.id }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take, + include: { grantor: { select: GRANT_PARTY_SELECT } }, + }), + ), + ]); // Revoked and expired rows come back too. They are the consent record — "who // had access, from when to when, and who ended it" is the question the table @@ -122,10 +120,7 @@ export const GET = apiHandler(async () => { const now = new Date(); // One cap per direction, one signal: either list cut short is a partial // answer. - const cap = { - truncated: givenCap.truncated || receivedCap.truncated, - limit: MAX_GRANTS_PER_DIRECTION, - }; + const cap = combinedCap(givenCap, receivedCap); return apiListSuccess( { given: given.map((g) => toGrantView(g, g.grantee, now)), diff --git a/src/app/api/allergies/route.ts b/src/app/api/allergies/route.ts index f184c3e3e..57a6b0153 100644 --- a/src/app/api/allergies/route.ts +++ b/src/app/api/allergies/route.ts @@ -15,13 +15,11 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { - apiError, apiSuccess, getClientIp, returnAllZodIssues, safeJson, } from "@/lib/api-response"; -import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { withIdempotency } from "@/lib/idempotency"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { @@ -29,7 +27,8 @@ import { allergyListQuerySchema, } from "@/lib/validations/allergy"; import { toAllergyDTO } from "@/lib/records/dto"; -import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap"; +import { apiListSuccess } from "@/lib/api/list-cap"; +import { listPage } from "@/lib/http/list-query"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireRecordAuth("read", "profile"); @@ -46,16 +45,19 @@ export const GET = apiHandler(async (request: NextRequest) => { } const limit = parsed.data.limit ?? 100; - const found = await prisma.allergy.findMany({ - where: { - userId: user.id, - deletedAt: null, - ...(parsed.data.includeInactive === "false" ? { status: "ACTIVE" } : {}), - }, - orderBy: { createdAt: "desc" }, - take: overfetch(limit), - }); - const { rows, cap } = capRows(found, limit); + const { rows, cap } = await listPage(limit, (take) => + prisma.allergy.findMany({ + where: { + userId: user.id, + deletedAt: null, + ...(parsed.data.includeInactive === "false" + ? { status: "ACTIVE" } + : {}), + }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take, + }), + ); annotate({ action: { name: "allergy.list", entity_type: "allergy" }, @@ -88,17 +90,7 @@ async function postAllergy(request: NextRequest): Promise { // A READ or WRITE delegate reaches neither: the destination is not on their // Settings list, and this line refuses them with `sharing.access.denied` // regardless. - const { user, actor } = await requireRecordAuth("manage", "profile"); - - // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The - // batch siblings have always been capped; the per-record creates a looping - // client hits were not. - const writeRl = await checkRecordWriteRateLimit(actor.id); - if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429, { - errorCode: "record_write.rate_limited", - }); - } + const { user } = await requireRecordAuth("manage", "profile"); const { data: rawBody, error: jsonError } = await safeJson(request, { maxBytes: 16 * 1024, diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index fff6a3129..2cdc57ea3 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -5,7 +5,7 @@ import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { analyticsQuerySchema } from "@/lib/validations/analytics"; import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; -import { summarize, type DataPoint } from "@/lib/analytics/trends"; +import { summarize, wireSummary, type DataPoint } from "@/lib/analytics/trends"; import { readSummariesSlice } from "@/lib/analytics/summaries-slice"; import { loadRecoveryEmpty } from "@/lib/insights/recovery-empty-read"; import { resolveEffectiveBpTargets } from "@/lib/analytics/effective-range"; @@ -500,8 +500,10 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) { glucoseRows, (r) => r.glucoseContext, )) { - glucoseByContext[bucket] = summarize( - ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })), + glucoseByContext[bucket] = wireSummary( + summarize( + ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })), + ), ); } diff --git a/src/app/api/auth/me/security-activity/__tests__/route.test.ts b/src/app/api/auth/me/security-activity/__tests__/route.test.ts new file mode 100644 index 000000000..fa66d8dc2 --- /dev/null +++ b/src/app/api/auth/me/security-activity/__tests__/route.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + auditLog: { findMany: vi.fn(), create: vi.fn() }, + }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +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 { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "u", role: "USER" as const, locale: "en" }, +}; + +const req = (qs = "") => + new NextRequest(`http://localhost/api/auth/me/security-activity${qs}`); + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.auditLog.findMany).mockResolvedValue([] as never); +}); + +describe("GET /api/auth/me/security-activity", () => { + it("reads one row past the requested page, in a total order", async () => { + const res = await GET(req("?limit=10")); + expect(res.status).toBe(200); + const call = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0] as { + take: number; + orderBy: unknown; + }; + expect(call.take).toBe(11); + expect(call.orderBy).toEqual([{ createdAt: "desc" }, { id: "desc" }]); + const body = (await res.json()) as { + meta: { truncated: boolean; limit: number }; + }; + expect(body.meta).toMatchObject({ truncated: false, limit: 10 }); + }); + + it("answers a limit past the published ceiling with a 422 naming it", async () => { + const res = await GET(req("?limit=500")); + expect(res.status).toBe(422); + const body = (await res.json()) as { + meta: { errorCode: string }; + details: { issues: Array<{ path: string }> }; + }; + expect(body.meta.errorCode).toBe("security_activity.invalid"); + expect(body.details.issues.map((i) => i.path)).toEqual(["limit"]); + expect(prisma.auditLog.findMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/auth/me/security-activity/route.ts b/src/app/api/auth/me/security-activity/route.ts index b88411fe9..a364f3464 100644 --- a/src/app/api/auth/me/security-activity/route.ts +++ b/src/app/api/auth/me/security-activity/route.ts @@ -14,7 +14,9 @@ import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { securityActivityWhere } from "@/lib/auth/security-activity"; import { maskIp } from "@/lib/auth/device-fingerprint"; -import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap"; +import { apiListSuccess } from "@/lib/api/list-cap"; +import { listPage, parseListQuery } from "@/lib/http/list-query"; +import { returnAllZodIssues } from "@/lib/api-response"; export const dynamic = "force-dynamic"; @@ -32,28 +34,34 @@ export interface SecurityActivityDTO { export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const limitParam = Number( - request.nextUrl.searchParams.get("limit") ?? DEFAULT_LIMIT, - ); - const limit = - Number.isFinite(limitParam) && limitParam > 0 - ? Math.min(Math.floor(limitParam), MAX_LIMIT) - : DEFAULT_LIMIT; - - const found = await prisma.auditLog.findMany({ - where: securityActivityWhere(user.id), - orderBy: { createdAt: "desc" }, - take: overfetch(limit), - select: { - action: true, - createdAt: true, - location: true, - ipAddress: true, - carrier: true, - }, + // The shared list query: an out-of-range limit is a 422 naming it, as on + // every other list, rather than a silent clamp to something not asked for. + const query = parseListQuery(request.nextUrl.searchParams, { + defaultLimit: DEFAULT_LIMIT, + maxLimit: MAX_LIMIT, }); + if (!query.success) { + return returnAllZodIssues(query.error, 422, { + errorCode: "security_activity.invalid", + }); + } + const { limit } = query.data; + + const { rows, cap } = await listPage(limit, (take) => + prisma.auditLog.findMany({ + where: securityActivityWhere(user.id), + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take, + select: { + action: true, + createdAt: true, + location: true, + ipAddress: true, + carrier: true, + }, + }), + ); - const { rows, cap } = capRows(found, limit); const events: SecurityActivityDTO[] = rows.map((r) => ({ action: r.action, createdAt: r.createdAt.toISOString(), diff --git a/src/app/api/auth/me/timezone/__tests__/route.test.ts b/src/app/api/auth/me/timezone/__tests__/route.test.ts index 183b1fa9b..ca3670583 100644 --- a/src/app/api/auth/me/timezone/__tests__/route.test.ts +++ b/src/app/api/auth/me/timezone/__tests__/route.test.ts @@ -23,14 +23,6 @@ vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); -// v1.37.19 — the zone change enqueues a compliance re-fold. -const refoldMock = vi.hoisted(() => - vi.fn(async () => ({ enqueued: true, error: null })), -); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - enqueueUserMedicationComplianceBackfill: refoldMock, -})); - // The supplement nutrient rows are keyed by the same local day. const nutrientMock = vi.hoisted(() => vi.fn(async () => ({ queued: true }))); vi.mock("@/lib/medications/ingredients/recompute-job", () => ({ @@ -110,11 +102,9 @@ describe("PUT /api/auth/me/timezone", () => { expect(res.status).toBe(422); }); - // Watched red: with the re-fold enqueue removed from the route (the - // pre-v1.37.19 write), the changed-zone case fails — the compliance - // rollup's day keys were minted under the old zone and nothing ever - // re-bucketed them (the coverage probe compares counts, not keys). - it("enqueues a compliance re-fold when the zone actually changes", async () => { + // The supplement nutrient rows' day keys were minted under the old zone; + // without the enqueue nothing re-buckets them. + it("enqueues the nutrient recompute when the zone actually changes", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.user.findUnique).mockResolvedValue({ timezone: "Europe/Berlin", @@ -123,11 +113,10 @@ describe("PUT /api/auth/me/timezone", () => { const res = await PUT(mkReq({ timezone: "America/New_York" })); expect(res.status).toBe(200); - expect(refoldMock).toHaveBeenCalledWith("user-1"); expect(nutrientMock).toHaveBeenCalledWith("user-1"); }); - it("does not re-fold when the zone is unchanged", async () => { + it("does not recompute when the zone is unchanged", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.user.findUnique).mockResolvedValue({ timezone: "Europe/Berlin", @@ -136,7 +125,6 @@ describe("PUT /api/auth/me/timezone", () => { const res = await PUT(mkReq({ timezone: "Europe/Berlin" })); expect(res.status).toBe(200); - expect(refoldMock).not.toHaveBeenCalled(); expect(nutrientMock).not.toHaveBeenCalled(); }); diff --git a/src/app/api/auth/me/timezone/route.ts b/src/app/api/auth/me/timezone/route.ts index 729760646..254a2b4c9 100644 --- a/src/app/api/auth/me/timezone/route.ts +++ b/src/app/api/auth/me/timezone/route.ts @@ -30,7 +30,6 @@ import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { invalidateUserTimezone, isValidTimezone } from "@/lib/tz/resolver"; import { enqueueSupplementNutrientRecompute } from "@/lib/medications/ingredients/recompute-job"; -import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; export const PUT = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -62,24 +61,13 @@ export const PUT = apiHandler(async (request: NextRequest) => { invalidateUserTimezone(user.id); - // v1.37.19 — the medication-compliance rollup keys its rows by - // the LOCAL day the dose fell on, minted under the timezone in force at - // write time. A zone change re-buckets those days, so the stored keys go - // stale (and the coverage probe compares counts, not keys — it cannot - // notice). Re-fold the trailing window through the ordinary backfill - // job; best-effort, the singleton key collapses rapid re-picks. + // The supplement nutrient rows are keyed by the LOCAL day the dose fell + // on, minted under the timezone in force at write time. A zone change + // re-buckets those days, so the account's rows are recomputed through + // the ordinary job; best-effort, the singleton key collapses rapid + // re-picks. if ((before?.timezone ?? null) !== tz) { - const refold = await enqueueUserMedicationComplianceBackfill(user.id); - // The supplement nutrient rows are keyed by the same local day. await enqueueSupplementNutrientRecompute(user.id); - annotate({ - meta: { - timezone_compliance_refold_enqueued: refold.enqueued, - ...(refold.error - ? { timezone_compliance_refold_error: refold.error } - : {}), - }, - }); } await auditLog("user.timezone.update", { diff --git a/src/app/api/biomarkers/[id]/merge/route.ts b/src/app/api/biomarkers/[id]/merge/route.ts index ea266a552..3c0e55465 100644 --- a/src/app/api/biomarkers/[id]/merge/route.ts +++ b/src/app/api/biomarkers/[id]/merge/route.ts @@ -31,7 +31,6 @@ import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { withIdempotency } from "@/lib/idempotency"; import { mergeBiomarkers as runMerge } from "@/lib/labs/biomarker-merge"; import { annotate } from "@/lib/logging/context"; -import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { destroyedDetails } from "@/lib/sharing/audit-details"; import { biomarkerMergeSchema } from "@/lib/validations/biomarker-merge"; @@ -45,16 +44,9 @@ async function mergeBiomarkers( request: NextRequest, { params }: RouteParams, ): Promise { - const { user, actor } = await requireRecordAuth("manage", "labs"); + const { user } = await requireRecordAuth("manage", "labs"); const { id } = await params; - const writeRl = await checkRecordWriteRateLimit(actor.id); - if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429, { - errorCode: "record_write.rate_limited", - }); - } - const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 4 * 1024, }); diff --git a/src/app/api/biomarkers/route.ts b/src/app/api/biomarkers/route.ts index 2ff94ce4f..a68d39152 100644 --- a/src/app/api/biomarkers/route.ts +++ b/src/app/api/biomarkers/route.ts @@ -9,7 +9,6 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; -import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { withIdempotency } from "@/lib/idempotency"; @@ -76,17 +75,7 @@ async function postBiomarker(request: NextRequest) { // duplicate pre-check and the `@@unique([userId, name])` backstop both scope // to the same resolved id, so a delegate adding a marker the owner already // tracks gets the ordinary 409 rather than a second copy. - const { user, actor } = await requireRecordAuth("write", "labs"); - - // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The - // batch siblings have always been capped; the per-record creates a looping - // client hits were not. - const writeRl = await checkRecordWriteRateLimit(actor.id); - if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429, { - errorCode: "record_write.rate_limited", - }); - } + const { user } = await requireRecordAuth("write", "labs"); const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 16 * 1024, diff --git a/src/app/api/custom-metrics/[id]/__tests__/route.test.ts b/src/app/api/custom-metrics/[id]/__tests__/route.test.ts index 17c2aae11..bc70c9d62 100644 --- a/src/app/api/custom-metrics/[id]/__tests__/route.test.ts +++ b/src/app/api/custom-metrics/[id]/__tests__/route.test.ts @@ -53,6 +53,22 @@ const EXISTING = { deletedAt: null, }; +/** + * The row as the reads load it: with the newest live entry and the live entry + * count joined on, which the published row carries as `latest` and + * `entryCount`. + */ +const JOINS = { + entries: [ + { + value: 42.5, + unit: "kg", + measuredAt: new Date("2026-06-03T07:00:00.000Z"), + }, + ], + _count: { entries: 7 }, +}; + const params = (id: string) => ({ params: Promise.resolve({ id }) }); function patchReq(body: unknown): NextRequest { @@ -77,17 +93,54 @@ beforeEach(() => { describe("GET /api/custom-metrics/[id]", () => { it("returns the metric for its owner", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.customMetric.findFirst).mockResolvedValue( - EXISTING as never, - ); + vi.mocked(prisma.customMetric.findFirst).mockResolvedValue({ + ...EXISTING, + ...JOINS, + } as never); const res = await GET( new NextRequest("http://localhost/api/custom-metrics/cm-1"), params("cm-1"), ); expect(res.status).toBe(200); - const body = (await res.json()) as { data: { name: string } | null }; + const body = (await res.json()) as { + data: { + name: string; + latest: { value: number; measuredAt: string } | null; + entryCount: number; + } | null; + }; expect(body.data?.name).toBe("Grip strength"); + // The published row carries the latest value and the count on its own, + // as it does in the catalogue; the read asks for both joins. + expect(body.data?.latest).toEqual({ + value: 42.5, + unit: "kg", + measuredAt: "2026-06-03T07:00:00.000Z", + }); + expect(body.data?.entryCount).toBe(7); + const findCall = vi.mocked(prisma.customMetric.findFirst).mock + .calls[0][0] as { include?: Record }; + expect(Object.keys(findCall.include ?? {})).toEqual(["entries", "_count"]); + }); + + it("answers latest null and a zero count for a metric with no entries", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.customMetric.findFirst).mockResolvedValue({ + ...EXISTING, + entries: [], + _count: { entries: 0 }, + } as never); + + const res = await GET( + new NextRequest("http://localhost/api/custom-metrics/cm-1"), + params("cm-1"), + ); + const body = (await res.json()) as { + data: { latest: unknown; entryCount: number }; + }; + expect(body.data.latest).toBeNull(); + expect(body.data.entryCount).toBe(0); }); it("404s a cross-user metric", async () => { @@ -113,6 +166,7 @@ describe("PATCH /api/custom-metrics/[id]", () => { ); vi.mocked(prisma.customMetric.update).mockResolvedValue({ ...EXISTING, + ...JOINS, targetLow: 20, targetHigh: 50, } as never); @@ -126,6 +180,12 @@ describe("PATCH /api/custom-metrics/[id]", () => { .calls[0][0] as { data: Record }; expect(updateCall.data.targetLow).toBe(20); expect(updateCall.data.targetHigh).toBe(50); + // The edit echo is the same published row as the read. + const body = (await res.json()) as { + data: { entryCount: number; latest: { value: number } | null }; + }; + expect(body.data.entryCount).toBe(7); + expect(body.data.latest?.value).toBe(42.5); }); it("persists only the explicit correlation opt-in field", async () => { @@ -135,6 +195,7 @@ describe("PATCH /api/custom-metrics/[id]", () => { ); vi.mocked(prisma.customMetric.update).mockResolvedValue({ ...EXISTING, + ...JOINS, correlationEnabled: true, } as never); diff --git a/src/app/api/custom-metrics/[id]/entries/route.ts b/src/app/api/custom-metrics/[id]/entries/route.ts index bb4477ede..2e1832ad5 100644 --- a/src/app/api/custom-metrics/[id]/entries/route.ts +++ b/src/app/api/custom-metrics/[id]/entries/route.ts @@ -9,7 +9,6 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; -import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { auditLog } from "@/lib/auth/audit"; import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate"; import { serialiseCustomMetricEntry } from "@/lib/custom-metrics/custom-metric-store"; @@ -108,15 +107,6 @@ async function postCustomMetricEntry( // was never wrong, and the GET arm above stays delegable meanwhile. const { user } = await requireAuth(); - // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The - // batch siblings have always been capped; the per-record creates a looping - // client hits were not. - const writeRl = await checkRecordWriteRateLimit(user.id); - if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429, { - errorCode: "record_write.rate_limited", - }); - } const { id } = await params; const metric = await prisma.customMetric.findFirst({ diff --git a/src/app/api/custom-metrics/[id]/route.ts b/src/app/api/custom-metrics/[id]/route.ts index de09d2910..6ba09b4c0 100644 --- a/src/app/api/custom-metrics/[id]/route.ts +++ b/src/app/api/custom-metrics/[id]/route.ts @@ -11,7 +11,10 @@ import { } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate"; -import { serialiseCustomMetric } from "@/lib/custom-metrics/custom-metric-store"; +import { + CUSTOM_METRIC_LATEST_INCLUDE, + serialiseCustomMetricWithLatest, +} from "@/lib/custom-metrics/custom-metric-store"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { updateCustomMetricSchema } from "@/lib/validations/custom-metrics"; @@ -41,8 +44,11 @@ export const GET = apiHandler( const { user } = await requireRecordAuth("read", "measurements"); const { id } = await params; + // The same joins as the list read: the published row carries the latest + // value and the entry count, on its own as in the catalogue. const row = await prisma.customMetric.findFirst({ where: { id, deletedAt: null }, + include: CUSTOM_METRIC_LATEST_INCLUDE, }); if (!row || row.userId !== user.id) { return apiError("Custom metric not found", 404); @@ -53,7 +59,13 @@ export const GET = apiHandler( meta: { customMetricId: id }, }); - return apiSuccess(serialiseCustomMetric(row)); + return apiSuccess( + serialiseCustomMetricWithLatest({ + ...row, + latest: row.entries[0] ?? null, + entryCount: row._count.entries, + }), + ); }, ); @@ -135,7 +147,11 @@ export const PATCH = apiHandler( return apiError("targetLow must not exceed targetHigh", 422); } - const updated = await prisma.customMetric.update({ where: { id }, data }); + const updated = await prisma.customMetric.update({ + where: { id }, + data, + include: CUSTOM_METRIC_LATEST_INCLUDE, + }); await auditLog("customMetric.update", { userId: user.id, @@ -149,7 +165,13 @@ export const PATCH = apiHandler( }); invalidateUserCorrelationPatterns(user.id); - return apiSuccess(serialiseCustomMetric(updated)); + return apiSuccess( + serialiseCustomMetricWithLatest({ + ...updated, + latest: updated.entries[0] ?? null, + entryCount: updated._count.entries, + }), + ); }, ); diff --git a/src/app/api/custom-metrics/route.ts b/src/app/api/custom-metrics/route.ts index d4d98b477..3dd654c55 100644 --- a/src/app/api/custom-metrics/route.ts +++ b/src/app/api/custom-metrics/route.ts @@ -11,7 +11,10 @@ import { } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate"; -import { serialiseCustomMetricWithLatest } from "@/lib/custom-metrics/custom-metric-store"; +import { + CUSTOM_METRIC_LATEST_INCLUDE, + serialiseCustomMetricWithLatest, +} from "@/lib/custom-metrics/custom-metric-store"; import { prisma } from "@/lib/db"; import { withIdempotency } from "@/lib/idempotency"; import { annotate } from "@/lib/logging/context"; @@ -34,15 +37,7 @@ export const GET = apiHandler(async () => { const rows = await prisma.customMetric.findMany({ where: { userId: user.id, deletedAt: null }, orderBy: { name: "asc" }, - include: { - entries: { - where: { deletedAt: null }, - orderBy: { measuredAt: "desc" }, - take: 1, - select: { value: true, unit: true, measuredAt: true }, - }, - _count: { select: { entries: { where: { deletedAt: null } } } }, - }, + include: CUSTOM_METRIC_LATEST_INCLUDE, }); annotate({ diff --git a/src/app/api/cycle/cycles/route.ts b/src/app/api/cycle/cycles/route.ts index 73da843d4..f7de2c2d4 100644 --- a/src/app/api/cycle/cycles/route.ts +++ b/src/app/api/cycle/cycles/route.ts @@ -19,7 +19,8 @@ import { toMenstrualCycleDTO } from "@/lib/cycle/dto"; import { median, mad } from "@/lib/cycle"; import { dayDiff } from "@/lib/cycle/day-math"; import { MIN_CYCLES_TO_PREDICT } from "@/lib/cycle/types"; -import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap"; +import { apiListSuccess } from "@/lib/api/list-cap"; +import { listPage } from "@/lib/http/list-query"; const DEFAULT_LIMIT = 24; @@ -51,12 +52,13 @@ export const GET = apiHandler(async (request: NextRequest) => { } const limit = parsed.data.limit ?? DEFAULT_LIMIT; - const found = await prisma.menstrualCycle.findMany({ - where: { userId: user.id, deletedAt: null }, - orderBy: { startDate: "desc" }, - take: overfetch(limit), - }); - const { rows, cap } = capRows(found, limit); + const { rows, cap } = await listPage(limit, (take) => + prisma.menstrualCycle.findMany({ + where: { userId: user.id, deletedAt: null }, + orderBy: [{ startDate: "desc" }, { id: "desc" }], + take, + }), + ); // Completed lengths (non-null lengthDays) drive the length stats. const lengths = rows diff --git a/src/app/api/cycle/day-logs/bulk/route.ts b/src/app/api/cycle/day-logs/bulk/route.ts index 66abea65a..b005cfa7f 100644 --- a/src/app/api/cycle/day-logs/bulk/route.ts +++ b/src/app/api/cycle/day-logs/bulk/route.ts @@ -57,7 +57,10 @@ interface EntryResult { export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk)); async function postBulk(request: NextRequest): Promise { - const { user } = await requireAuth(); + // Metered by this route's own bulk bucket below, not the shared per-account + // write bucket: the native client syncs through here in bursts (see + // `meterAccountWrite`). + const { user } = await requireAuth(undefined, { writeBucket: "own" }); const gate = await requireCycleEnabled(user.id, user.gender); if (!gate.enabled) return gate.response; diff --git a/src/app/api/cycle/day-logs/route.ts b/src/app/api/cycle/day-logs/route.ts index 158d85c84..3f776a0bc 100644 --- a/src/app/api/cycle/day-logs/route.ts +++ b/src/app/api/cycle/day-logs/route.ts @@ -26,7 +26,6 @@ import { returnAllZodIssues, safeJson, } from "@/lib/api-response"; -import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { withIdempotency } from "@/lib/idempotency"; import { requireCycleEnabled } from "@/lib/cycle/gate"; import { @@ -95,21 +94,11 @@ async function postDayLog(request: NextRequest): Promise { // v1.37.0 — MANAGE. The upsert replaces the day, which is what edit means // here; the gate below resolves `user.gender` against the RECORD, so the // module follows the record rather than the caller. - const { user, actor } = await requireRecordAuth("manage", "cycle"); + const { user } = await requireRecordAuth("manage", "cycle"); const gate = await requireCycleEnabled(user.id, user.gender); if (!gate.enabled) return gate.response; - // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The - // batch siblings have always been capped; the per-record creates a looping - // client hits were not. - const writeRl = await checkRecordWriteRateLimit(actor.id); - if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429, { - errorCode: "record_write.rate_limited", - }); - } - const { data: rawBody, error: jsonError } = await safeJson(request, { maxBytes: 64 * 1024, }); diff --git a/src/app/api/documents/inbound/[id]/chat/route.ts b/src/app/api/documents/inbound/[id]/chat/route.ts index d37ff416e..49a24fdd7 100644 --- a/src/app/api/documents/inbound/[id]/chat/route.ts +++ b/src/app/api/documents/inbound/[id]/chat/route.ts @@ -71,7 +71,9 @@ type RouteParams = { params: Promise<{ id: string }> }; export const POST = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // Metered by the document chat's own bucket below, not the shared + // ceiling. + const { user } = await requireAuth(undefined, { writeBucket: "own" }); const userId = user.id; const gate = await requireModuleEnabled(userId, "inboundDocuments"); diff --git a/src/app/api/documents/inbound/[id]/suggest/route.ts b/src/app/api/documents/inbound/[id]/suggest/route.ts index da9d78fcc..47371f779 100644 --- a/src/app/api/documents/inbound/[id]/suggest/route.ts +++ b/src/app/api/documents/inbound/[id]/suggest/route.ts @@ -73,7 +73,9 @@ type RouteParams = { params: Promise<{ id: string }> }; export const POST = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // Writes nothing (the draft goes back to the edit form), so the shared + // write ceiling does not count it; the document-AI bucket meters it. + const { user } = await requireAuth(undefined, { readOverPost: true }); const gate = await requireModuleEnabled(user.id, "inboundDocuments"); if (!gate.enabled) return gate.response; diff --git a/src/app/api/documents/inbound/route.ts b/src/app/api/documents/inbound/route.ts index f2c6723d4..86f68721c 100644 --- a/src/app/api/documents/inbound/route.ts +++ b/src/app/api/documents/inbound/route.ts @@ -50,6 +50,7 @@ import { sanitiseZodIssues, } from "@/lib/api-response"; import { getAiCapability } from "@/lib/ai/capabilities/gate"; +import { listPage } from "@/lib/http/list-query"; import { prisma } from "@/lib/db"; import { hashQueryTokens } from "@/lib/documents/content-index"; import { @@ -710,16 +711,17 @@ export const GET = apiHandler(async (request: Request) => { // Hardening: the list NEVER fetches the encrypted blob column — a page of // 50 rows would otherwise drag up to 50 × cap ciphertext bytes per request. - const rows = await prisma.inboundDocument.findMany({ - where, - omit: { contentEncrypted: true }, - orderBy, - take: limit + 1, - ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), - }); + const { rows: page, cap } = await listPage(limit, (take) => + prisma.inboundDocument.findMany({ + where, + omit: { contentEncrypted: true }, + orderBy, + take, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + }), + ); - const hasMore = rows.length > limit; - const page = hasMore ? rows.slice(0, limit) : rows; + const hasMore = cap.truncated; const nextCursor = hasMore ? (page[page.length - 1]?.id ?? null) : null; // Fact tallies for the whole page in ONE grouped count — no per-document diff --git a/src/app/api/encounters/route.ts b/src/app/api/encounters/route.ts index ef90cc80b..5686620b2 100644 --- a/src/app/api/encounters/route.ts +++ b/src/app/api/encounters/route.ts @@ -25,7 +25,8 @@ import { returnAllZodIssues, safeJson, } from "@/lib/api-response"; -import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; +import { apiListSuccess } from "@/lib/api/list-cap"; +import { combinedCap, listPage } from "@/lib/http/list-query"; import { withIdempotency } from "@/lib/idempotency"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { @@ -109,27 +110,33 @@ export const GET = apiHandler(async (request: NextRequest) => { // past reads newest-first, which is the opposite direction; resolving both // here means the client renders two lists instead of splitting and // re-sorting one, and the ordering lives in one place. - const [upcoming, past] = await Promise.all([ - prisma.encounter.findMany({ - // `gt` and `gte` are different keys, so the caller's lower bound and this - // arm's floor both survive the spread; only the `lte` pair collided. - where: { ...where, occurredAt: { ...where.occurredAt, gt: now } }, - orderBy: [{ occurredAt: "asc" }, { id: "asc" }], - take, - skip, - include: ENCOUNTER_INCLUDE, - }), - prisma.encounter.findMany({ - where: { - ...where, - occurredAt: { ...where.occurredAt, lte: pastCeiling }, - }, - orderBy: [{ occurredAt: "desc" }, { id: "desc" }], - take, - skip, - include: ENCOUNTER_INCLUDE, - }), - ]); + const [{ rows: upcoming, cap: upcomingCap }, { rows: past, cap: pastCap }] = + await Promise.all([ + listPage(take, (overTake) => + prisma.encounter.findMany({ + // `gt` and `gte` are different keys, so the caller's lower bound and + // this arm's floor both survive the spread; only the `lte` pair + // collided. + where: { ...where, occurredAt: { ...where.occurredAt, gt: now } }, + orderBy: [{ occurredAt: "asc" }, { id: "asc" }], + take: overTake, + skip, + include: ENCOUNTER_INCLUDE, + }), + ), + listPage(take, (overTake) => + prisma.encounter.findMany({ + where: { + ...where, + occurredAt: { ...where.occurredAt, lte: pastCeiling }, + }, + orderBy: [{ occurredAt: "desc" }, { id: "desc" }], + take: overTake, + skip, + include: ENCOUNTER_INCLUDE, + }), + ), + ]); // The links ride the list. The card counts what a visit produced, and the // edit sheet seeds its pickers from the row it was handed — a list without @@ -152,24 +159,14 @@ export const GET = apiHandler(async (request: NextRequest) => { upcoming: upcoming.map((row) => toEncounterDTO(row, links.get(row.id))), past: past.map((row) => toEncounterDTO(row, links.get(row.id))), }; - return apiSuccess(body); + return apiListSuccess(body, combinedCap(upcomingCap, pastCap)); }); // A double-tap on "save" must not file the same visit twice. export const POST = apiHandler(withIdempotency<[NextRequest]>(postEncounter)); async function postEncounter(request: NextRequest): Promise { - const { user, actor, grantId } = await requireRecordAuth("write", "profile"); - - // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The - // batch siblings have always been capped; the per-record creates a looping - // client hits were not. - const writeRl = await checkRecordWriteRateLimit(actor.id); - if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429, { - errorCode: "record_write.rate_limited", - }); - } + const { user, grantId } = await requireRecordAuth("write", "profile"); const { data: rawBody, error: jsonError } = await safeJson(request, { maxBytes: 32 * 1024, diff --git a/src/app/api/family-history/route.ts b/src/app/api/family-history/route.ts index cb1464501..a8937161b 100644 --- a/src/app/api/family-history/route.ts +++ b/src/app/api/family-history/route.ts @@ -28,7 +28,8 @@ import { familyHistoryListQuerySchema, } from "@/lib/validations/family-history"; import { toFamilyHistoryEntryDTO } from "@/lib/records/dto"; -import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap"; +import { apiListSuccess } from "@/lib/api/list-cap"; +import { listPage } from "@/lib/http/list-query"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireRecordAuth("read", "profile"); @@ -44,12 +45,13 @@ export const GET = apiHandler(async (request: NextRequest) => { } const limit = parsed.data.limit ?? 100; - const found = await prisma.familyHistoryEntry.findMany({ - where: { userId: user.id, deletedAt: null }, - orderBy: { createdAt: "desc" }, - take: overfetch(limit), - }); - const { rows, cap } = capRows(found, limit); + const { rows, cap } = await listPage(limit, (take) => + prisma.familyHistoryEntry.findMany({ + where: { userId: user.id, deletedAt: null }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + take, + }), + ); annotate({ action: { name: "family-history.list", entity_type: "family_history" }, diff --git a/src/app/api/feature-flags/__tests__/route.test.ts b/src/app/api/feature-flags/__tests__/route.test.ts index d1db1da2e..cec44a7be 100644 --- a/src/app/api/feature-flags/__tests__/route.test.ts +++ b/src/app/api/feature-flags/__tests__/route.test.ts @@ -150,7 +150,10 @@ describe("GET /api/feature-flags", () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); FIND.mockResolvedValue(null); const res = await GET(req()); - expect(res.headers.get("Deprecation")).toBe("true"); + // RFC 9745: the deprecation date as `@` and Unix seconds — 2026-09-24. + expect(res.headers.get("Deprecation")).toBe("@1790208000"); + // RFC 8594: the first day it may stop answering. + expect(res.headers.get("Sunset")).toBe("Wed, 31 Mar 2027 00:00:00 GMT"); expect(res.headers.get("Link")).toBe( '; rel="successor-version"', ); diff --git a/src/app/api/feature-flags/route.ts b/src/app/api/feature-flags/route.ts index 237bfc19e..20c3330bf 100644 --- a/src/app/api/feature-flags/route.ts +++ b/src/app/api/feature-flags/route.ts @@ -12,8 +12,10 @@ * "insightStatus", "documentAi" } } } * * The master is applied (every sub-switch reads false when it is off), and a - * switch read that fails answers every switch off. The response carries - * `Deprecation: true` and a `Link` to its successor. + * switch read that fails answers every switch off. The response carries the + * deprecation date, the sunset and a `Link` to its successor + * (`src/lib/http/deprecation.ts`), and the wide event names the client build + * that still asked. * * `Cache-Control: private, max-age=60`: switches flip rarely. */ @@ -22,9 +24,10 @@ import type { NextRequest } from "next/server"; import { apiHandler, requireActorAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; import { getAssistantFlags } from "@/lib/feature-flags"; +import { deprecated } from "@/lib/http/deprecation"; import { annotate } from "@/lib/logging/context"; -export const GET = apiHandler(async (_request: NextRequest) => { +export const GET = apiHandler(async (request: NextRequest) => { // An actor surface, and the easiest call in the set: the answer comes off // the `AppSettings` singleton and reads no user row at all, so there is no // record for a switch to substitute. It stays reachable while a switch is on @@ -45,7 +48,5 @@ export const GET = apiHandler(async (_request: NextRequest) => { const response = apiSuccess({ assistant }); response.headers.set("Cache-Control", "private, max-age=60"); - response.headers.set("Deprecation", "true"); - response.headers.set("Link", '; rel="successor-version"'); - return response; + return deprecated(response, "/api/feature-flags", request); }); diff --git a/src/app/api/illness/episodes/route.ts b/src/app/api/illness/episodes/route.ts index 57b997184..2c16236b9 100644 --- a/src/app/api/illness/episodes/route.ts +++ b/src/app/api/illness/episodes/route.ts @@ -32,7 +32,8 @@ import { illnessEpisodeListQuerySchema, } from "@/lib/validations/illness"; import { toIllnessEpisodeDTO } from "@/lib/illness/dto"; -import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap"; +import { apiListSuccess } from "@/lib/api/list-cap"; +import { listPage } from "@/lib/http/list-query"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireRecordAuth("read", "illness"); @@ -59,27 +60,30 @@ export const GET = apiHandler(async (request: NextRequest) => { const { from, to, q, offset, sortDir } = parsed.data; const limit = parsed.data.limit ?? 50; const order = sortDir ?? "desc"; - const found = await prisma.illnessEpisode.findMany({ - where: { - userId: user.id, - deletedAt: null, - ...(parsed.data.includeResolved === "false" ? { resolvedAt: null } : {}), - ...(from || to - ? { - onsetAt: { - ...(from ? { gte: new Date(from) } : {}), - ...(to ? { lte: new Date(to) } : {}), - }, - } - : {}), - ...(q ? { label: { contains: q, mode: "insensitive" as const } } : {}), - }, - // `id` breaks ties so offset paging never repeats or drops a row. - orderBy: [{ onsetAt: order }, { id: order }], - take: overfetch(limit), - skip: offset ?? 0, - }); - const { rows, cap } = capRows(found, limit); + const { rows, cap } = await listPage(limit, (take) => + prisma.illnessEpisode.findMany({ + where: { + userId: user.id, + deletedAt: null, + ...(parsed.data.includeResolved === "false" + ? { resolvedAt: null } + : {}), + ...(from || to + ? { + onsetAt: { + ...(from ? { gte: new Date(from) } : {}), + ...(to ? { lte: new Date(to) } : {}), + }, + } + : {}), + ...(q ? { label: { contains: q, mode: "insensitive" as const } } : {}), + }, + // `id` breaks ties so offset paging never repeats or drops a row. + orderBy: [{ onsetAt: order }, { id: order }], + take, + skip: offset ?? 0, + }), + ); annotate({ action: { name: "illness.episode.list", entity_type: "illness_episode" }, diff --git a/src/app/api/ingest/medication/__tests__/route.test.ts b/src/app/api/ingest/medication/__tests__/route.test.ts index 6d441ff39..421dee241 100644 --- a/src/app/api/ingest/medication/__tests__/route.test.ts +++ b/src/app/api/ingest/medication/__tests__/route.test.ts @@ -38,8 +38,8 @@ vi.mock("@/lib/rate-limit", () => ({ vi.mock("@/lib/app-settings", () => ({ isApiGloballyEnabled: vi.fn(), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +vi.mock("@/lib/medications/dose-day-refresh", () => ({ + refreshDoseSlot: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/medications/inventory/consumption", () => ({ consumeForIntake: vi.fn().mockResolvedValue(undefined), diff --git a/src/app/api/ingest/medication/route.ts b/src/app/api/ingest/medication/route.ts index 33fc0d326..e91694740 100644 --- a/src/app/api/ingest/medication/route.ts +++ b/src/app/api/ingest/medication/route.ts @@ -23,7 +23,7 @@ import { unstableExternalIdShape, } from "@/lib/validations/external-id"; import { isApiGloballyEnabled } from "@/lib/app-settings"; -import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh"; import { applyCanonicalSlotWrite, resolveSlotForWriteByBand, @@ -337,7 +337,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // v1.4.39 — refresh the compliance rollup for the ingested // event's user-day. - await recomputeMedicationComplianceForEvent({ + await refreshDoseSlot({ userId: apiToken.userId, medicationId: medication.id, scheduledFor: event.scheduledFor, diff --git a/src/app/api/insights/chat/fenced/route.ts b/src/app/api/insights/chat/fenced/route.ts index 1276a96de..66c5f1dc6 100644 --- a/src/app/api/insights/chat/fenced/route.ts +++ b/src/app/api/insights/chat/fenced/route.ts @@ -52,7 +52,8 @@ export const dynamic = "force-dynamic"; export const runtime = "nodejs"; export const POST = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + // Metered by the fenced chat's own bucket below, not the shared ceiling. + const { user } = await requireAuth(undefined, { writeBucket: "own" }); const userId = user.id; const gate = await requireModuleEnabled(userId, "inboundDocuments"); diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 3a3260f7a..5b8134950 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -81,7 +81,9 @@ const COACH_CONCURRENT_TURNS = 2; const COACH_TURN_SLOT_MS = TURN_LIMITS.user.wallMs + 30_000; async function handleChatRequest(request: NextRequest): Promise { - const auth = await requireAuth(); + // A turn is metered by the Coach's own buckets (`coach-chat`, the turn + // slot), not the shared write ceiling. + const auth = await requireAuth(undefined, { writeBucket: "own" }); // The `coach` capability, right after auth and before the rate limit and // the body parse. It folds in what the Coach module gate, the operator // switch and the `disableCoach` opt-out used to answer separately, plus diff --git a/src/app/api/insights/ecg/route.ts b/src/app/api/insights/ecg/route.ts index b7001d9d2..4a9cfd219 100644 --- a/src/app/api/insights/ecg/route.ts +++ b/src/app/api/insights/ecg/route.ts @@ -47,7 +47,8 @@ import { checkRateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/db"; import { persistEcgRecording } from "@/lib/ecg/persist-recording"; import { isoDateTime } from "@/lib/validations/datetime"; -import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap"; +import { apiListSuccess } from "@/lib/api/list-cap"; +import { listPage } from "@/lib/http/list-query"; export const dynamic = "force-dynamic"; @@ -134,25 +135,26 @@ export const GET = apiHandler(async () => { // sync. const { user } = await requireRecordAuth("manage", "record"); - const found = await prisma.ecgRecording.findMany({ - where: { userId: user.id }, - // Everything EXCEPT `waveformEncrypted` — the list never touches the - // encrypted blob, so no decrypt happens on this path. - select: { - id: true, - recordedAt: true, - durationSeconds: true, - samplingFrequency: true, - sampleCount: true, - averageHeartRate: true, - lead: true, - rhythmClassification: true, - source: true, - }, - orderBy: { recordedAt: "desc" }, - take: overfetch(MAX_RECORDINGS), - }); - const { rows, cap } = capRows(found, MAX_RECORDINGS); + const { rows, cap } = await listPage(MAX_RECORDINGS, (take) => + prisma.ecgRecording.findMany({ + where: { userId: user.id }, + // Everything EXCEPT `waveformEncrypted` — the list never touches the + // encrypted blob, so no decrypt happens on this path. + select: { + id: true, + recordedAt: true, + durationSeconds: true, + samplingFrequency: true, + sampleCount: true, + averageHeartRate: true, + lead: true, + rhythmClassification: true, + source: true, + }, + orderBy: [{ recordedAt: "desc" }, { id: "desc" }], + take, + }), + ); const recordings = rows.map((r) => ({ id: r.id, @@ -201,7 +203,10 @@ export const GET = apiHandler(async () => { * to the same row by construction rather than by a cached response. */ export const POST = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + // Metered by this route's own bulk bucket below, not the shared per-account + // write bucket: the native client syncs through here in bursts (see + // `meterAccountWrite`). + const { user } = await requireAuth(undefined, { writeBucket: "own" }); // A device ingest carries no model text: switching AI off, or the AI // analysis opt-out, must never refuse a recording. diff --git a/src/app/api/insights/rhythm-events/route.ts b/src/app/api/insights/rhythm-events/route.ts index 6cddeb6ac..c73206455 100644 --- a/src/app/api/insights/rhythm-events/route.ts +++ b/src/app/api/insights/rhythm-events/route.ts @@ -29,7 +29,8 @@ import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { EVENT_MEASUREMENT_TYPES } from "@/lib/validations/measurement"; import type { MeasurementType } from "@/generated/prisma/client"; -import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap"; +import { apiListSuccess } from "@/lib/api/list-cap"; +import { listPage } from "@/lib/http/list-query"; export const dynamic = "force-dynamic"; @@ -49,24 +50,25 @@ export const GET = apiHandler(async () => { // The `insights` module is the AI analysis opt-out, and nothing here was // written by a model. - const found = await prisma.measurement.findMany({ - where: { - userId: user.id, - type: { in: EVENT_TYPE_LIST }, - deletedAt: null, - }, - select: { - id: true, - type: true, - rhythmClassification: true, - measuredAt: true, - source: true, - deviceType: true, - }, - orderBy: { measuredAt: "desc" }, - take: overfetch(MAX_EVENTS), - }); - const { rows, cap } = capRows(found, MAX_EVENTS); + const { rows, cap } = await listPage(MAX_EVENTS, (take) => + prisma.measurement.findMany({ + where: { + userId: user.id, + type: { in: EVENT_TYPE_LIST }, + deletedAt: null, + }, + select: { + id: true, + type: true, + rhythmClassification: true, + measuredAt: true, + source: true, + deviceType: true, + }, + orderBy: [{ measuredAt: "desc" }, { id: "desc" }], + take, + }), + ); const events = rows.map((r) => ({ id: r.id, diff --git a/src/app/api/labs/ocr/commit/__tests__/route.test.ts b/src/app/api/labs/ocr/commit/__tests__/route.test.ts index b855c1fd6..435dd77eb 100644 --- a/src/app/api/labs/ocr/commit/__tests__/route.test.ts +++ b/src/app/api/labs/ocr/commit/__tests__/route.test.ts @@ -33,6 +33,12 @@ vi.mock("@/lib/rate-limit", async (importOriginal) => ({ resetAt: new Date(Date.now() + 60_000), })), })); +// The real meter, so the ceiling is proven on the path it now takes: the route +// resolves its caller, the resolver charges the bucket. The unit setup stubs +// the meter for every other route test. +vi.mock("@/lib/auth/write-meter", async (importOriginal) => + importOriginal(), +); const tzMock = vi.hoisted(() => ({ zone: "UTC" })); vi.mock("@/lib/tz/resolver", async (importOriginal) => ({ ...(await importOriginal()), @@ -216,7 +222,7 @@ describe("POST /api/labs/ocr/commit dedup across the user's day", () => { }); describe("POST /api/labs/ocr/commit write ceiling", () => { - it("charges the shared per-account record-write bucket", async () => { + it("charges the shared per-account record-write bucket where the caller is resolved", async () => { const res = await POST(postReq({ rows: [numericRow(95)] })); expect(res.status).toBe(200); expect(checkRecordWriteRateLimit).toHaveBeenCalledWith("user-1"); diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index 3dd653fc7..b9bd332f0 100644 --- a/src/app/api/labs/ocr/commit/route.ts +++ b/src/app/api/labs/ocr/commit/route.ts @@ -20,7 +20,6 @@ import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { - apiError, apiSuccess, getClientIp, returnAllZodIssues, @@ -31,7 +30,6 @@ import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { withIdempotency } from "@/lib/idempotency"; -import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { emitDataArrival } from "@/lib/arrivals/emit-shared"; import { rememberImportAlias } from "@/lib/labs/biomarker-aliases"; @@ -159,17 +157,6 @@ function inBatchKey( async function commitOcrRows(request: NextRequest) { const { user } = await requireAuth(); - // Shared per-account write ceiling, the same bucket every other lab write - // charges (`POST /api/labs`, `POST /api/biomarkers`). A commit can write a - // whole report's rows and mint markers on the way, so it was the one lab - // write a looping client could repeat without any ceiling. - const writeRl = await checkRecordWriteRateLimit(user.id); - if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429, { - errorCode: "record_write.rate_limited", - }); - } - const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 256 * 1024, }); diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index 60d4dcfbc..31bd837a1 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -10,7 +10,6 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; -import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; @@ -156,17 +155,7 @@ async function postLabResult(request: NextRequest) { // it mints it under `user.id`: the marker joins the record's catalogue, not // the caller's, which is the only reading of "add a result to your record" // that leaves the owner with a usable catalogue afterwards. - const { user, actor } = await requireRecordAuth("write", "labs"); - - // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The - // batch siblings have always been capped; the per-record creates a looping - // client hits were not. - const writeRl = await checkRecordWriteRateLimit(actor.id); - if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429, { - errorCode: "record_write.rate_limited", - }); - } + const { user } = await requireRecordAuth("write", "labs"); const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 16 * 1024, diff --git a/src/app/api/life-events/route.ts b/src/app/api/life-events/route.ts index cdb0d32a0..3d9dd4ee4 100644 --- a/src/app/api/life-events/route.ts +++ b/src/app/api/life-events/route.ts @@ -21,7 +21,6 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { - apiError, apiSuccess, getClientIp, returnAllZodIssues, @@ -36,7 +35,6 @@ import { toLifeEventDTO, } from "@/lib/life-events/service"; import { annotate } from "@/lib/logging/context"; -import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; export const GET = apiHandler(async () => { const { user } = await requireAuth(); @@ -53,13 +51,6 @@ export const POST = apiHandler(withIdempotency<[NextRequest]>(postLifeEvent)); async function postLifeEvent(request: NextRequest): Promise { const { user } = await requireAuth(); - const writeRl = await checkRecordWriteRateLimit(user.id); - if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429, { - errorCode: "record_write.rate_limited", - }); - } - const { data: rawBody, error: jsonError } = await safeJson(request, { maxBytes: 16 * 1024, }); diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index f6e04823f..501b03fea 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -293,7 +293,12 @@ async function postBatch(request: NextRequest): Promise { // a native login mints. `requireAuth` refuses any acting-account carrier for // every credential shape, so the scoped token is confined to its owner's own // record here without this route adding an arm of its own. - const auth = await requireAuth(MEASUREMENTS_WRITE_SCOPE); + // Metered by this route's own bulk bucket below, not the shared per-account + // write bucket: the native client syncs through here in bursts (see + // `meterAccountWrite`). + const auth = await requireAuth(MEASUREMENTS_WRITE_SCOPE, { + writeBucket: "own", + }); const { user } = auth; // A scoped credential is a bridge — a scale, a home-automation rule — and not diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index e707e63d9..655666a85 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -17,7 +17,6 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; -import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { createMeasurementSchema, createBatchMeasurementSchema, @@ -403,16 +402,6 @@ async function postMeasurement(request: NextRequest) { const { user } = auth; const scoped = isScopedCredential(auth); - // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The - // batch siblings have always been capped; the per-record creates a looping - // client hits were not. - const writeRl = await checkRecordWriteRateLimit(auth.actor.id); - if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429, { - errorCode: "record_write.rate_limited", - }); - } - const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 64 * 1024, }); diff --git a/src/app/api/medications/[id]/__tests__/route.test.ts b/src/app/api/medications/[id]/__tests__/route.test.ts index 0713a9094..8cc13470d 100644 --- a/src/app/api/medications/[id]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/__tests__/route.test.ts @@ -51,9 +51,9 @@ vi.mock("@/lib/cache/invalidate", () => ({ // v1.16.1 — the schedule-replace branch recomputes the compliance // rollups of the tombstoned slots; stub the rollup module so the route // test never reaches `$executeRaw`. -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ +vi.mock("@/lib/medications/dose-day-refresh", () => ({ dayKeyForScheduledFor: vi.fn(() => "2026-06-10"), - recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined), + refreshDoseDay: vi.fn().mockResolvedValue(undefined), })); // v1.40 (#1024) — the course writer and reader run against their own // integration suite; here they answer "no course, nothing refused". @@ -99,8 +99,8 @@ import { import { auditLog } from "@/lib/auth/audit"; import { dayKeyForScheduledFor, - recomputeMedicationComplianceForDay, -} from "@/lib/rollups/medication-compliance-rollups"; + refreshDoseDay, +} from "@/lib/medications/dose-day-refresh"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -297,7 +297,7 @@ describe("PUT /api/medications/[id] — schedule replace archives a revision (v1 vi.mocked(getMedicationCategories).mockResolvedValue({}); vi.mocked(auditLog).mockResolvedValue(undefined); vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10"); - vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(undefined); + vi.mocked(refreshDoseDay).mockResolvedValue(undefined); vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([ PREVIOUS_ROW, ] as never); @@ -447,19 +447,19 @@ describe("PUT /api/medications/[id] — schedule replace migrates open slots", ( ROUTE_CTX, ); expect(res.status).toBe(200); - expect(recomputeMedicationComplianceForDay).toHaveBeenCalledTimes(2); - expect(recomputeMedicationComplianceForDay).toHaveBeenCalledWith( - "user-1", - "m1", - "2026-06-10", - "Europe/Berlin", - ); - expect(recomputeMedicationComplianceForDay).toHaveBeenCalledWith( - "user-1", - "m1", - "2026-06-11", - "Europe/Berlin", - ); + expect(refreshDoseDay).toHaveBeenCalledTimes(2); + expect(refreshDoseDay).toHaveBeenCalledWith({ + userId: "user-1", + medicationId: "m1", + dayKey: "2026-06-10", + tz: "Europe/Berlin", + }); + expect(refreshDoseDay).toHaveBeenCalledWith({ + userId: "user-1", + medicationId: "m1", + dayKey: "2026-06-11", + tz: "Europe/Berlin", + }); }); it("does NOT touch intake rows when the body carries no schedules array", async () => { @@ -865,7 +865,7 @@ describe("PUT /api/medications/[id] — intake tracking switch (#1033)", () => { vi.mocked(getMedicationCategories).mockResolvedValue({}); vi.mocked(auditLog).mockResolvedValue(undefined); vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10"); - vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(undefined); + vi.mocked(refreshDoseDay).mockResolvedValue(undefined); vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([ LIVE_ROW, ] as never); @@ -1055,7 +1055,7 @@ describe("PUT /api/medications/[id] — shipped client on a record-only medicati vi.mocked(getMedicationCategories).mockResolvedValue({}); vi.mocked(auditLog).mockResolvedValue(undefined); vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10"); - vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(undefined); + vi.mocked(refreshDoseDay).mockResolvedValue(undefined); vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([ STORED_ROW, ] as never); diff --git a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts index 9e92263a7..cedb34685 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts @@ -37,8 +37,8 @@ vi.mock("@/lib/medications/inventory/consumption", () => ({ consumeForIntake: vi.fn().mockResolvedValue(undefined), restoreForIntake: vi.fn().mockResolvedValue(undefined), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +vi.mock("@/lib/medications/dose-day-refresh", () => ({ + refreshDoseSlot: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index 615bbac81..4eaafb54d 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -19,7 +19,7 @@ import { import { reconcileOneShotState } from "@/lib/medications/lifecycle"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; -import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh"; import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz"; import { applyCanonicalSlotWrite, @@ -392,14 +392,14 @@ export const PUT = apiHandler( // scheduledFor day-keys when the dose moved across a day boundary // (band re-attribution can move it as well as an explicit edit); if the // slot is unchanged the second call collapses to a no-op upsert. - await recomputeMedicationComplianceForEvent({ + await refreshDoseSlot({ userId: user.id, medicationId: id, scheduledFor: event.scheduledFor, tz: user.timezone, }); if (slotMoved) { - await recomputeMedicationComplianceForEvent({ + await refreshDoseSlot({ userId: user.id, medicationId: id, scheduledFor: targetScheduledFor, @@ -488,7 +488,7 @@ export const DELETE = apiHandler( // v1.4.39 — refresh the rollup row for the day the deleted // event sat on. When that day now holds zero events the helper // drops the rollup row entirely. - await recomputeMedicationComplianceForEvent({ + await refreshDoseSlot({ userId: user.id, medicationId: id, scheduledFor: event.scheduledFor, diff --git a/src/app/api/medications/[id]/intake/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/__tests__/route.test.ts index 354ab4780..42b3eaf50 100644 --- a/src/app/api/medications/[id]/intake/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/route.test.ts @@ -78,8 +78,8 @@ vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserMedications: vi.fn(), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +vi.mock("@/lib/medications/dose-day-refresh", () => ({ + refreshDoseSlot: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); diff --git a/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts b/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts index 053deba20..759d6ff64 100644 --- a/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts @@ -86,8 +86,8 @@ vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserMedications: vi.fn(), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +vi.mock("@/lib/medications/dose-day-refresh", () => ({ + refreshDoseSlot: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/notifications/medication-intake-sync", () => ({ diff --git a/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts index eb99f97bc..a42c9fa5b 100644 --- a/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts @@ -46,12 +46,12 @@ vi.mock("@/lib/rate-limit", () => ({ rateLimitHeaders: vi.fn(() => ({ "X-RateLimit-Remaining": "0" })), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ +vi.mock("@/lib/medications/dose-day-refresh", () => ({ dayKeyForScheduledFor: vi.fn((d: Date) => { const iso = d.toISOString(); return iso.slice(0, 10); }), - recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined), + refreshDoseDay: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/medications/inventory/consumption", () => ({ @@ -98,8 +98,8 @@ import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { checkRateLimit } from "@/lib/rate-limit"; import { dayKeyForScheduledFor, - recomputeMedicationComplianceForDay, -} from "@/lib/rollups/medication-compliance-rollups"; + refreshDoseDay, +} from "@/lib/medications/dose-day-refresh"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -174,7 +174,7 @@ describe("POST /api/medications/[id]/intake/bulk-delete", () => { const json = await res.json(); expect(json.data).toEqual({ deleted: 0 }); expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled(); - expect(recomputeMedicationComplianceForDay).not.toHaveBeenCalled(); + expect(refreshDoseDay).not.toHaveBeenCalled(); // (#22) — a no-op delete queues no cross-device sync fan-out. expect(queueMedicationIntakeSync).not.toHaveBeenCalled(); }); @@ -201,7 +201,7 @@ describe("POST /api/medications/[id]/intake/bulk-delete", () => { expect(dayKeyForScheduledFor).toHaveBeenCalledTimes(3); // Three rows, two unique dayKeys → two recompute calls. - expect(recomputeMedicationComplianceForDay).toHaveBeenCalledTimes(2); + expect(refreshDoseDay).toHaveBeenCalledTimes(2); // (#22) — the whole bulk delete queues exactly ONE cross-device // sync fan-out, not one per removed row. diff --git a/src/app/api/medications/[id]/intake/bulk-delete/route.ts b/src/app/api/medications/[id]/intake/bulk-delete/route.ts index 948db196b..75b0c5f98 100644 --- a/src/app/api/medications/[id]/intake/bulk-delete/route.ts +++ b/src/app/api/medications/[id]/intake/bulk-delete/route.ts @@ -15,8 +15,8 @@ import { restoreForIntake } from "@/lib/medications/inventory/consumption"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { dayKeyForScheduledFor, - recomputeMedicationComplianceForDay, -} from "@/lib/rollups/medication-compliance-rollups"; + refreshDoseDay, +} from "@/lib/medications/dose-day-refresh"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { NextRequest } from "next/server"; @@ -159,26 +159,11 @@ export const POST = apiHandler( }, }); - // Best-effort rollup refresh — one trip per unique dayKey so a - // 14-row delete spanning two days closes in two SQL trips. The - // helper is idempotent and the catch swallows transient - // populator failures (same contract as the single-event delete). + // Best-effort dose-day refresh, one per unique dayKey, so a 14-row + // delete spanning two days refreshes two days (same contract as the + // single-event delete). for (const dayKey of dayKeysToRefresh) { - try { - await recomputeMedicationComplianceForDay(user.id, id, dayKey, tz); - } catch (err) { - annotate({ - action: { - name: "medications.intake.bulk-delete.recompute-failed", - entity_type: "medication", - entity_id: id, - }, - meta: { - day_key: dayKey, - error: err instanceof Error ? err.message : String(err), - }, - }); - } + await refreshDoseDay({ userId: user.id, medicationId: id, dayKey, tz }); } await auditLog("medication.intake.bulk_delete", { diff --git a/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts index 3af9531b4..06e1dd824 100644 --- a/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts @@ -2,9 +2,9 @@ * v1.5.5 — purge route fires the medication cache bundle. * * Pins the contract the detail-page Verlauf-löschen cascade depends - * on: dropping the rollup rows is necessary but not sufficient. The + * on: tombstoning the rows is necessary but not sufficient. The * analytics + iOS today-tally + dashboard tiles all read off cached - * shapes that survive the rollup delete for their TTL; the bundle + * shapes that survive the tombstoning for their TTL; the bundle * invalidation forces every downstream reader to converge on the * post-purge counts in the same tick the user sees the toast. */ @@ -16,7 +16,6 @@ vi.mock("@/lib/db", () => ({ prisma: { medication: { findUnique: vi.fn() }, medicationIntakeEvent: { updateMany: vi.fn() }, - medicationComplianceRollup: { deleteMany: vi.fn() }, // v1.43 — no composition, so no supplement recompute is queued. medicationIngredient: { findFirst: vi.fn().mockResolvedValue(null) }, }, @@ -87,9 +86,6 @@ beforeEach(() => { vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({ count: 7, } as never); - vi.mocked(prisma.medicationComplianceRollup.deleteMany).mockResolvedValue({ - count: 5, - } as never); }); describe("DELETE /api/medications/[id]/intake/purge", () => { diff --git a/src/app/api/medications/[id]/intake/purge/route.ts b/src/app/api/medications/[id]/intake/purge/route.ts index 2885701d7..1737eac0c 100644 --- a/src/app/api/medications/[id]/intake/purge/route.ts +++ b/src/app/api/medications/[id]/intake/purge/route.ts @@ -17,8 +17,8 @@ type RouteParams = { params: Promise<{ id: string }> }; export const DELETE = apiHandler( async (request: NextRequest, { params }: RouteParams) => { // v1.37.0 — MANAGE. It tombstones rather than deletes (since the - // delegated-write release) and drops only rollups that recompute, so the - // history it clears is reconstructable from the rows themselves. + // delegated-write release), so the history it clears is reconstructable + // from the rows themselves. const { user } = await requireRecordAuth("manage", "medications"); const { id } = await params; @@ -60,13 +60,6 @@ export const DELETE = apiHandler( data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, }); - // v1.4.39 — drop every rollup row for this medication so the - // next compliance read returns zero-filled buckets rather than the - // last-known scheduled / taken totals for now-deleted events. - await prisma.medicationComplianceRollup.deleteMany({ - where: { medicationId: id, userId: user.id }, - }); - // v1.43 — the purged doses no longer count toward the micronutrients. if (await medicationHasComposition(user.id, id)) { await enqueueSupplementNutrientRecompute(user.id); diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index 0ecd6c9d1..b3b7fa966 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -10,7 +10,6 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; -import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { intakeSchema, listIntakeEventsSchema, @@ -36,7 +35,7 @@ import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear"; import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses"; import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake"; -import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh"; import { applyCanonicalSlotWrite, findPinConflict, @@ -62,16 +61,6 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { "medications", ); - // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The - // batch siblings have always been capped; the per-record creates a looping - // client hits were not. - const writeRl = await checkRecordWriteRateLimit(actor.id); - if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429, { - errorCode: "record_write.rate_limited", - }); - } - // v1.36.1 — the sibling route `POST /api/medications/intake` // refuses a delegate changing a dose the owner already recorded. This route // reaches the same rows through the slot upsert, so without the same rule it @@ -608,7 +597,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { // v1.4.39 — refresh the persistent compliance rollup for the // affected day. The hook is best-effort; failures annotate but never // block the user's POST response. - await recomputeMedicationComplianceForEvent({ + await refreshDoseSlot({ userId: user.id, medicationId: id, scheduledFor: event.scheduledFor, diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index a1821e376..e711a713d 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -48,8 +48,8 @@ import { } from "@/lib/medications/scheduling/next-due"; import { dayKeyForScheduledFor, - recomputeMedicationComplianceForDay, -} from "@/lib/rollups/medication-compliance-rollups"; + refreshDoseDay, +} from "@/lib/medications/dose-day-refresh"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { effectiveUnitsPerDose, @@ -716,9 +716,9 @@ export const PUT = apiHandler( // Tombstone today's and future open pending rows of this medication. // Runs on a schedule replace (the old anchors are stale) and when intake // tracking is switched off (nothing is due any more). - // The rollup days the tombstoned slots sat on, recomputed once the + // The dose days the tombstoned slots sat on, refreshed once the // transaction below has committed the tombstones. - const staleRollupDays = new Set(); + const staleDoseDays = new Set(); const tombstoneOpenSlots = async ( db: Prisma.TransactionClient, ): Promise => { @@ -768,7 +768,7 @@ export const PUT = apiHandler( }); // Deduped via the user-tz day key. for (const row of tombstoned) { - staleRollupDays.add(dayKeyForScheduledFor(row.scheduledFor, userTz)); + staleDoseDays.add(dayKeyForScheduledFor(row.scheduledFor, userTz)); } }; @@ -1085,26 +1085,16 @@ export const PUT = apiHandler( throw err; } - // Recompute each rollup day the tombstoned slots sat on, now that the - // tombstones are committed. Best-effort like every other rollup - // write-hook: a failure is annotated, never fails the edit. - for (const dayKey of staleRollupDays) { - try { - await recomputeMedicationComplianceForDay( - user.id, - id, - dayKey, - user.timezone || DEFAULT_TIMEZONE, - ); - } catch (err) { - annotate({ - meta: { - medication_compliance_rollup_failed: true, - medication_compliance_rollup_error: - err instanceof Error ? err.message : String(err), - }, - }); - } + // Refresh each dose day the tombstoned slots sat on, now that the + // tombstones are committed. Best-effort like every dose write-hook: a + // failure is reported, never fails the edit. + for (const dayKey of staleDoseDays) { + await refreshDoseDay({ + userId: user.id, + medicationId: id, + dayKey, + tz: user.timezone || DEFAULT_TIMEZONE, + }); } // Apply the v1.5.5 primary-schedule grace bridge after the diff --git a/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts b/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts index e5d2109ff..afe537197 100644 --- a/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts +++ b/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts @@ -45,7 +45,6 @@ import { toRevisionPayloadEntry, type ScheduleRevisionEntry, } from "@/lib/medications/scheduling/schedule-eras"; -import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import type { Prisma } from "@/generated/prisma/client"; @@ -286,13 +285,10 @@ export const PATCH = apiHandler( }, }); - // The corrected era re-segments history; refresh the pre-aggregated - // compliance rollups asynchronously (best-effort). // v1.16.9 — an era write re-segments the bands every cached payload // (list next-due, compliance cells, dashboard tally) was built on; // hard-evict so the next read reflects the new history immediately. invalidateUserMedications(user.id, { evict: true }); - await enqueueUserMedicationComplianceBackfill(user.id); return apiSuccess({ id: outcome.revision.id, @@ -404,13 +400,10 @@ export const DELETE = apiHandler( meta: { revision_id: revisionId }, }); - // History re-segments without the era; refresh the pre-aggregated - // compliance rollups asynchronously (best-effort). // v1.16.9 — an era write re-segments the bands every cached payload // (list next-due, compliance cells, dashboard tally) was built on; // hard-evict so the next read reflects the new history immediately. invalidateUserMedications(user.id, { evict: true }); - await enqueueUserMedicationComplianceBackfill(user.id); return apiSuccess({ deleted: true }); }, diff --git a/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts b/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts index 93986e9e1..a54c4b903 100644 --- a/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts @@ -42,11 +42,6 @@ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - enqueueUserMedicationComplianceBackfill: vi - .fn() - .mockResolvedValue({ enqueued: true, error: null }), -})); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), @@ -64,7 +59,6 @@ import { GET, POST } from "../route"; import { DELETE, PATCH } from "../[revisionId]/route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; -import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -349,11 +343,6 @@ describe("POST /api/medications/[id]/schedule-revisions", () => { expect(createArgs.data.payload[0].rrule).toBe("FREQ=DAILY"); expect(createArgs.data.payload[0].scheduleType).toBe("SCHEDULED"); - // History re-segmented — the rollup backfill must be enqueued. - expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith( - "user-1", - ); - // Check-then-insert ran inside the transaction with the // per-medication `FOR UPDATE` lock taken first. expect(prisma.$transaction).toHaveBeenCalled(); @@ -432,9 +421,6 @@ describe("DELETE /api/medications/[id]/schedule-revisions/[revisionId]", () => { where: { medicationId: "m1", supersededByRevisionId: "rev-m" }, data: { supersededByRevisionId: null }, }); - expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith( - "user-1", - ); }); }); @@ -533,9 +519,6 @@ describe("PATCH /api/medications/[id]/schedule-revisions/[revisionId]", () => { expect(updateArgs.data.payload[0].rrule).toBe("FREQ=DAILY"); // In-place — no superseding row is minted. expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled(); - expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith( - "user-1", - ); // Check-then-write ran inside the locked transaction. expect(prisma.$transaction).toHaveBeenCalled(); expect(prisma.$queryRaw).toHaveBeenCalled(); @@ -573,8 +556,5 @@ describe("PATCH /api/medications/[id]/schedule-revisions/[revisionId]", () => { where: { id: "rev-t" }, data: { supersededByRevisionId: "rev-fix" }, }); - expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith( - "user-1", - ); }); }); diff --git a/src/app/api/medications/[id]/schedule-revisions/route.ts b/src/app/api/medications/[id]/schedule-revisions/route.ts index 20994aa84..8f27bb9c6 100644 --- a/src/app/api/medications/[id]/schedule-revisions/route.ts +++ b/src/app/api/medications/[id]/schedule-revisions/route.ts @@ -40,7 +40,6 @@ import { toRevisionPayloadEntry, type ScheduleRevisionEntry, } from "@/lib/medications/scheduling/schedule-eras"; -import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import type { Prisma } from "@/generated/prisma/client"; @@ -282,14 +281,10 @@ export const POST = apiHandler( }, }); - // The new era re-segments history the compliance rollups already - // pre-aggregated; refresh them asynchronously. Best-effort like - // every other rollup write-hook. // v1.16.9 — an era write re-segments the bands every cached payload // (list next-due, compliance cells, dashboard tally) was built on; // hard-evict so the next read reflects the new history immediately. invalidateUserMedications(user.id, { evict: true }); - await enqueueUserMedicationComplianceBackfill(user.id); return apiSuccess( { diff --git a/src/app/api/medications/[id]/side-effects/route.ts b/src/app/api/medications/[id]/side-effects/route.ts index 0b12feae8..5efd00c8d 100644 --- a/src/app/api/medications/[id]/side-effects/route.ts +++ b/src/app/api/medications/[id]/side-effects/route.ts @@ -39,7 +39,8 @@ import { categoryForEntry } from "@/lib/medications/side-effects/taxonomy"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { encryptNote, shapeSideEffectNotes } from "@/lib/crypto/note-cipher"; import { withIdempotency } from "@/lib/idempotency"; -import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap"; +import { apiListSuccess } from "@/lib/api/list-cap"; +import { listPage } from "@/lib/http/list-query"; type RouteParams = { params: Promise<{ id: string }> }; @@ -67,23 +68,24 @@ export const GET = apiHandler( const { from, to, limit } = parsed.data; - const found = await prisma.medicationSideEffect.findMany({ - where: { - userId: user.id, - medicationId: id, - ...(from || to - ? { - occurredAt: { - ...(from ? { gte: from } : {}), - ...(to ? { lt: to } : {}), - }, - } - : {}), - }, - orderBy: { occurredAt: "desc" }, - take: overfetch(limit), - }); - const { rows: items, cap } = capRows(found, limit); + const { rows: items, cap } = await listPage(limit, (take) => + prisma.medicationSideEffect.findMany({ + where: { + userId: user.id, + medicationId: id, + ...(from || to + ? { + occurredAt: { + ...(from ? { gte: from } : {}), + ...(to ? { lt: to } : {}), + }, + } + : {}), + }, + orderBy: [{ occurredAt: "desc" }, { id: "desc" }], + take, + }), + ); annotate({ action: { name: "medication.sideEffect.list" }, diff --git a/src/app/api/medications/intake/__tests__/route.test.ts b/src/app/api/medications/intake/__tests__/route.test.ts index c1ff368b0..87d35347f 100644 --- a/src/app/api/medications/intake/__tests__/route.test.ts +++ b/src/app/api/medications/intake/__tests__/route.test.ts @@ -34,12 +34,6 @@ vi.mock("@/lib/db", () => ({ // v1.15.18 — the band resolver loads the medication via findFirst. findFirst: vi.fn(), }, - medicationComplianceRollup: { - findFirst: vi.fn(), - findMany: vi.fn(), - upsert: vi.fn(), - deleteMany: vi.fn(), - }, // v1.4.43 — audit-ledger breadcrumb for validation-failed paths. auditLog: { create: vi.fn() }, $transaction: vi.fn(), @@ -119,15 +113,6 @@ beforeEach(() => { vi.mocked(prisma.medicationIntakeEvent.createMany).mockResolvedValue({ count: 0, } as never); - // v1.4.39 — default the coverage probe to "uncovered" so the - // legacy compliance test still exercises the live-fallback branch - // and finds the legacy mocked intake events. - vi.mocked(prisma.medicationComplianceRollup.findFirst).mockResolvedValue( - null, - ); - vi.mocked(prisma.medicationComplianceRollup.findMany).mockResolvedValue( - [] as never, - ); // v1.4.39 — the coverage probe is now a single `$queryRaw` // aggregate returning `{ rolled_days, event_days }`. Default to // "zero rollups, zero events" (covered/trivial-empty) so tests that diff --git a/src/app/api/medications/intake/bulk/__tests__/route.test.ts b/src/app/api/medications/intake/bulk/__tests__/route.test.ts index f72e787a3..bd530c53c 100644 --- a/src/app/api/medications/intake/bulk/__tests__/route.test.ts +++ b/src/app/api/medications/intake/bulk/__tests__/route.test.ts @@ -51,8 +51,8 @@ vi.mock("@/lib/medications/inventory/consumption", () => ({ consumeForIntake: vi.fn().mockResolvedValue(undefined), restoreForIntake: vi.fn().mockResolvedValue(undefined), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined), +vi.mock("@/lib/medications/dose-day-refresh", () => ({ + refreshDoseDay: vi.fn().mockResolvedValue(undefined), dayKeyForScheduledFor: vi.fn().mockReturnValue("2026-01-01"), })); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 7b9847940..c3d3e65f7 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -65,9 +65,9 @@ import { } from "@/lib/integrations/healthkit-type-sync"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { - recomputeMedicationComplianceForDay, dayKeyForScheduledFor, -} from "@/lib/rollups/medication-compliance-rollups"; + refreshDoseDay, +} from "@/lib/medications/dose-day-refresh"; import { applyCanonicalSlotWrite, findPinConflict, @@ -197,7 +197,10 @@ interface EntryResult { export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk)); async function postBulk(request: NextRequest): Promise { - const auth = await requireAuth(); + // Metered by this route's own bulk bucket below, not the shared per-account + // write bucket: the native client syncs through here in bursts (see + // `meterAccountWrite`). + const auth = await requireAuth(undefined, { writeBucket: "own" }); const { user } = auth; // A narrow, single-purpose credential (a bridge, an ingest token) is no // Apple Health sync, so it never writes the dose arrival record below. The @@ -365,10 +368,9 @@ async function postBulk(request: NextRequest): Promise { let extraDosesUnscheduled = 0; const skipped: Array<{ index: number; reason: string }> = []; const unstableShapes: UnstableExternalIdShape[] = []; - // v1.4.39 — collect distinct `(medicationId, dayKey)` pairs - // touched by the batch so one rollup recompute fires per pair after - // all inserts complete. Per-row recompute would balloon a 500-entry - // batch into 500 sequential rollup hits. + // Distinct `(medicationId, dayKey)` pairs touched by the batch, so one + // dose-day refresh fires per pair after all inserts complete. A per-row + // refresh would balloon a 500-entry batch into 500 sequential refreshes. const touchedDays = new Map< string, { medicationId: string; dayKey: string } @@ -949,30 +951,16 @@ async function postBulk(request: NextRequest): Promise { invalidateUserMedications(user.id, { evict: true }); } - // v1.4.39 — refresh one rollup row per distinct - // `(medicationId, dayKey)` touched by the batch. Best-effort: failures - // are swallowed by the helper's `recomputeMedicationComplianceForEvent` - // wrapper, but bulk callers wrap recompute directly to keep the - // tight ingest loop unaffected by populator issues. + // Refresh the derived rows of each distinct `(medicationId, dayKey)` the + // batch touched. Best-effort: the refresh reports its own failures and + // never fails the ingest. for (const { medicationId, dayKey } of touchedDays.values()) { - try { - await recomputeMedicationComplianceForDay( - user.id, - medicationId, - dayKey, - user.timezone, - ); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - annotate({ - meta: { - medication_compliance_rollup_bulk_failed: true, - medication_compliance_rollup_bulk_error: message, - medication_compliance_rollup_medication: medicationId, - medication_compliance_rollup_day: dayKey, - }, - }); - } + await refreshDoseDay({ + userId: user.id, + medicationId, + dayKey, + tz: user.timezone, + }); } // v1.17.1 (#22) — silent cross-device intake sync. ONE queued fan-out diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 98d92f114..27604ba9d 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -21,14 +21,13 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; -import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; -import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh"; import { buildScheduleAnchoredComplianceBuckets, type ScheduleAnchoredComplianceBucket, @@ -212,16 +211,6 @@ export const POST = apiHandler(async (request: NextRequest) => { // notification at the end of this handler. const { user, actor } = await requireRecordAuth("write", "medications"); - // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The - // batch siblings have always been capped; the per-record creates a looping - // client hits were not. - const writeRl = await checkRecordWriteRateLimit(actor.id); - if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429, { - errorCode: "record_write.rate_limited", - }); - } - const { data: body, error } = await safeJson(request, { maxBytes: 64 * 1024, }); @@ -573,7 +562,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // the affected day so the next read after the cache miss returns // the up-to-date `(scheduled, taken, skipped)` tuple. Best-effort: // a populator failure annotates ops without blocking the response. - await recomputeMedicationComplianceForEvent({ + await refreshDoseSlot({ userId: user.id, medicationId: existing.medicationId, scheduledFor: existing.scheduledFor, @@ -584,7 +573,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // source day self-corrects while the destination keeps its stale tuple. const movedTo = updated?.scheduledFor; if (movedTo && movedTo.getTime() !== existing.scheduledFor.getTime()) { - await recomputeMedicationComplianceForEvent({ + await refreshDoseSlot({ userId: user.id, medicationId: existing.medicationId, scheduledFor: movedTo, diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts index dd52b4443..610bceab3 100644 --- a/src/app/api/mental-health/assessments/route.ts +++ b/src/app/api/mental-health/assessments/route.ts @@ -57,7 +57,8 @@ import type { MeasurementType, MeasurementSource, } from "@/generated/prisma/client"; -import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap"; +import { apiListSuccess } from "@/lib/api/list-cap"; +import { listPage } from "@/lib/http/list-query"; function shapeRow(row: { id: string; @@ -120,33 +121,34 @@ export const GET = apiHandler(async (request: NextRequest) => { } const { instrument, limit, offset } = parsed.data; const pageSize = limit ?? 100; - const found = await prisma.mentalHealthAssessment.findMany({ - where: { - userId: user.id, - deletedAt: null, - // Screeners only: the table also stores AUDIT-C (v1.43), which older - // app builds cannot decode in this list. - instrument: instrument ?? { in: [...SCREENER_INSTRUMENTS] }, - }, - // Unique tiebreaker — `takenAt` is day-resolution and two instruments - // taken in one sitting tie. - orderBy: [{ takenAt: "desc" }, { id: "desc" }], - take: overfetch(pageSize), - skip: offset ?? 0, - select: { - id: true, - instrument: true, - locale: true, - version: true, - totalScore: true, - severityBand: true, - item9Flagged: true, - crisisShownAt: true, - takenAt: true, - createdAt: true, - }, - }); - const { rows, cap } = capRows(found, pageSize); + const { rows, cap } = await listPage(pageSize, (take) => + prisma.mentalHealthAssessment.findMany({ + where: { + userId: user.id, + deletedAt: null, + // Screeners only: the table also stores AUDIT-C (v1.43), which older + // app builds cannot decode in this list. + instrument: instrument ?? { in: [...SCREENER_INSTRUMENTS] }, + }, + // Unique tiebreaker — `takenAt` is day-resolution and two instruments + // taken in one sitting tie. + orderBy: [{ takenAt: "desc" }, { id: "desc" }], + take, + skip: offset ?? 0, + select: { + id: true, + instrument: true, + locale: true, + version: true, + totalScore: true, + severityBand: true, + item9Flagged: true, + crisisShownAt: true, + takenAt: true, + createdAt: true, + }, + }), + ); annotate({ action: { name: "mental-health.list" }, meta: { count: rows.length }, diff --git a/src/app/api/mood-entries/[id]/__tests__/route.test.ts b/src/app/api/mood-entries/[id]/__tests__/route.test.ts index 1b96a0f40..19727e41f 100644 --- a/src/app/api/mood-entries/[id]/__tests__/route.test.ts +++ b/src/app/api/mood-entries/[id]/__tests__/route.test.ts @@ -68,7 +68,7 @@ vi.mock("next/headers", () => ({ })), })); -import { PUT } from "../route"; +import { GET, PUT } from "../route"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { getSession } from "@/lib/auth/session"; @@ -138,6 +138,43 @@ beforeEach(() => { ); }); +describe("GET /api/mood-entries/[id] — the list's wire shape", () => { + it("carries the structured tags and rated factors, as the list does", async () => { + vi.mocked(prisma.moodEntry.findFirst).mockResolvedValue({ + ...EXISTING_ENTRY, + tagLinks: [ + { rating: null, moodTag: { key: "sleep_good", kind: "BINARY" } }, + { rating: 4, moodTag: { key: "energy", kind: "RATED" } }, + ], + context: null, + } as never); + + const res = await GET( + new NextRequest("http://localhost/api/mood-entries/me1"), + ROUTE_CTX, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + tagKeys: string[]; + ratedFactors: Array<{ key: string; rating: number }>; + tagLinks?: unknown; + }; + }; + expect(body.data.tagKeys).toEqual(["sleep_good"]); + expect(body.data.ratedFactors).toEqual([{ key: "energy", rating: 4 }]); + // The join is projected, never shipped raw. + expect(body.data.tagLinks).toBeUndefined(); + const findCall = vi.mocked(prisma.moodEntry.findFirst).mock.calls[0][0] as { + include?: Record; + }; + expect(Object.keys(findCall.include ?? {}).sort()).toEqual([ + "context", + "tagLinks", + ]); + }); +}); + describe("PUT /api/mood-entries/[id] — 422 multi-issue (v1.4.43 W6)", () => { it("surfaces TWO simultaneous validation errors", async () => { // Bad `mood` enum + bad `moodLoggedAt`. diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index bfb537a1e..7730d4d01 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -15,10 +15,11 @@ import { Prisma } from "@/generated/prisma/client"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { moodDateKey } from "@/lib/mood/date-key"; +import { MOOD_ENTRY_WIRE_INCLUDE, toMoodEntryWire } from "@/lib/mood/list-read"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { deriveA1, shapeLevelA } from "@/lib/mood/level-a"; import { contextForWire, persistMoodContext } from "@/lib/mood/context"; -import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher"; +import { encryptNote } from "@/lib/crypto/note-cipher"; import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; import { @@ -50,9 +51,9 @@ export const GET = apiHandler( // (not `findUnique`) because `deletedAt` is not part of a unique index. const entry = await prisma.moodEntry.findFirst({ where: { id, deletedAt: null }, - // v1.38 — the day context rides with the entry rather than behind a - // second request: the edit surface needs both or neither. - include: { context: true }, + // The same joins and the same projection as the list read, so an + // entry opened on its own carries `tagKeys` and `ratedFactors` too. + include: MOOD_ENTRY_WIRE_INCLUDE, }); if (!entry || entry.userId !== user.id) { @@ -64,14 +65,7 @@ export const GET = apiHandler( meta: { moodEntryId: id }, }); - const { context, ...row } = entry; - return apiSuccess({ - // v1.37 — level-A values under the keys the write path takes. - ...shapeLevelA(shapeMoodNote(row)), - tags: parseTags(row.tags), - // v1.38 — lists decoded, note decrypted, ciphertext dropped. - context: context ? contextForWire(context) : null, - }); + return apiSuccess(toMoodEntryWire(entry)); }, ); diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index 8a606cf7d..b0564355e 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -194,7 +194,10 @@ interface EntryResult { export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk)); async function postBulk(request: NextRequest): Promise { - const { user } = await requireAuth(); + // Metered by this route's own bulk bucket below, not the shared per-account + // write bucket: the native client syncs through here in bursts (see + // `meterAccountWrite`). + const { user } = await requireAuth(undefined, { writeBucket: "own" }); const rl = await checkRateLimit( `mood-entries:bulk:${user.id}`, diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 94a8c4a82..67f38fe4d 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -8,7 +8,6 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; -import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { createMoodEntrySchema, listMoodEntriesSchema, @@ -109,16 +108,6 @@ async function postMoodEntry(request: NextRequest) { // the `externalId` refusal below. const { user, actor } = await requireRecordAuth("manage", "mind"); - // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The - // batch siblings have always been capped; the per-record creates a looping - // client hits were not. - const writeRl = await checkRecordWriteRateLimit(actor.id); - if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429, { - errorCode: "record_write.rate_limited", - }); - } - const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 64 * 1024, }); diff --git a/src/app/api/nutrients/batch/route.ts b/src/app/api/nutrients/batch/route.ts index 04a3c31a2..ac2d88f2c 100644 --- a/src/app/api/nutrients/batch/route.ts +++ b/src/app/api/nutrients/batch/route.ts @@ -79,7 +79,10 @@ interface EntryResult { export const POST = apiHandler(withIdempotency<[NextRequest]>(postBatch)); async function postBatch(request: NextRequest): Promise { - const { user } = await requireAuth(); + // Metered by this route's own bulk bucket below, not the shared per-account + // write bucket: the native client syncs through here in bursts (see + // `meterAccountWrite`). + const { user } = await requireAuth(undefined, { writeBucket: "own" }); // Module gate first: a disabled module must refuse before any body // work, so iOS can branch on the 403 `module.disabled` errorCode and diff --git a/src/app/api/personal-records/__tests__/route.test.ts b/src/app/api/personal-records/__tests__/route.test.ts index a4498ca4a..2593e518d 100644 --- a/src/app/api/personal-records/__tests__/route.test.ts +++ b/src/app/api/personal-records/__tests__/route.test.ts @@ -66,7 +66,7 @@ describe("GET /api/personal-records", () => { expect(body.error).toBeNull(); expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({ where: { userId: "user-1" }, - orderBy: { achievedAt: "desc" }, + orderBy: [{ achievedAt: "desc" }, { id: "desc" }], // One row past the limit, so the response can say it was cut. take: 101, }); @@ -80,7 +80,7 @@ describe("GET /api/personal-records", () => { expect(res.status).toBe(200); expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({ where: { userId: "user-1", metricType: "VO2_MAX" }, - orderBy: { achievedAt: "desc" }, + orderBy: [{ achievedAt: "desc" }, { id: "desc" }], // One row past the limit, so the response can say it was cut. take: 101, }); @@ -114,7 +114,7 @@ describe("GET /api/personal-records", () => { expect(res.status).toBe(200); expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({ where: { userId: "user-1" }, - orderBy: { achievedAt: "desc" }, + orderBy: [{ achievedAt: "desc" }, { id: "desc" }], // One row past the limit, so the response can say it was cut. take: 26, }); @@ -128,7 +128,7 @@ describe("GET /api/personal-records", () => { expect(res.status).toBe(200); expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({ where: { userId: "user-1" }, - orderBy: { achievedAt: "desc" }, + orderBy: [{ achievedAt: "desc" }, { id: "desc" }], // One row past the limit, so the response can say it was cut. take: 501, }); diff --git a/src/app/api/personal-records/route.ts b/src/app/api/personal-records/route.ts index dea22ef2a..ffbc29a06 100644 --- a/src/app/api/personal-records/route.ts +++ b/src/app/api/personal-records/route.ts @@ -38,7 +38,8 @@ import { resolveRecordBenchmark, } from "@/lib/personal-records/record-benchmark"; import { toProfileSex } from "@/lib/profile/sex"; -import { apiListSuccess, capRows, overfetch } from "@/lib/api/list-cap"; +import { apiListSuccess } from "@/lib/api/list-cap"; +import { listPage } from "@/lib/http/list-query"; export const dynamic = "force-dynamic"; @@ -63,15 +64,16 @@ export const GET = apiHandler(async (request: NextRequest) => { } const { metricType, limit } = parsed.data; - const found = await prisma.personalRecord.findMany({ - where: { - userId: user.id, - ...(metricType ? { metricType } : {}), - }, - orderBy: { achievedAt: "desc" }, - take: overfetch(limit), - }); - const { rows: records, cap } = capRows(found, limit); + const { rows: records, cap } = await listPage(limit, (take) => + prisma.personalRecord.findMany({ + where: { + userId: user.id, + ...(metricType ? { metricType } : {}), + }, + orderBy: [{ achievedAt: "desc" }, { id: "desc" }], + take, + }), + ); const wantsBand = records.some((r) => mayCarryBenchmark(r.metricType)); const profile = wantsBand diff --git a/src/app/api/practitioners/[id]/route.ts b/src/app/api/practitioners/[id]/route.ts index 3377dd7b2..0d1f8819d 100644 --- a/src/app/api/practitioners/[id]/route.ts +++ b/src/app/api/practitioners/[id]/route.ts @@ -7,9 +7,10 @@ * read by id and then checked against the resolved user, so a foreign id and a * missing id give the same 404 and probing learns nothing. * - * Deleting a practitioner keeps every visit that named it. The foreign key is - * SetNull, so the visits survive with no practice attached rather than - * disappearing with the address-book entry. + * Deleting a practitioner takes it out of the address book and the pickers, + * not out of the history: every live visit and dose that named it keeps + * showing it, and the nightly purge keeps the tombstone for as long as one + * does (`soft-delete-retention.ts`). */ import { NextRequest } from "next/server"; diff --git a/src/app/api/practitioners/route.ts b/src/app/api/practitioners/route.ts index 39e15a3a2..8cc7ac970 100644 --- a/src/app/api/practitioners/route.ts +++ b/src/app/api/practitioners/route.ts @@ -33,6 +33,8 @@ import { practitionerListQuerySchema, } from "@/lib/validations/practitioners"; import { toPractitionerDTO } from "@/lib/practitioners/dto"; +import { apiListSuccess } from "@/lib/api/list-cap"; +import { listPage } from "@/lib/http/list-query"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireRecordAuth("read", "profile"); @@ -41,6 +43,7 @@ export const GET = apiHandler(async (request: NextRequest) => { const parsed = practitionerListQuerySchema.safeParse({ q: params.get("q") ?? undefined, limit: params.get("limit") ?? undefined, + offset: params.get("offset") ?? undefined, }); if (!parsed.success) { return returnAllZodIssues(parsed.error, 422, { @@ -48,50 +51,55 @@ export const GET = apiHandler(async (request: NextRequest) => { }); } - const rows = await prisma.practitioner.findMany({ - where: { - userId: user.id, - deletedAt: null, - // Three plaintext columns, because a person looks a practice up by - // whichever they remember: the doctor's name on the referral, the - // practice name on the door, or the field they need ("Zahnmedizin"). - // Searching only name + practice made the specialty attempt return - // nothing and read as "not in the address book". - ...(parsed.data.q - ? { - OR: [ - { - name: { - contains: parsed.data.q, - mode: "insensitive" as const, + const limit = parsed.data.limit ?? 100; + const { rows, cap } = await listPage(limit, (take) => + prisma.practitioner.findMany({ + where: { + userId: user.id, + deletedAt: null, + // Three plaintext columns, because a person looks a practice up by + // whichever they remember: the doctor's name on the referral, the + // practice name on the door, or the field they need ("Zahnmedizin"). + // Searching only name + practice made the specialty attempt return + // nothing and read as "not in the address book". + ...(parsed.data.q + ? { + OR: [ + { + name: { + contains: parsed.data.q, + mode: "insensitive" as const, + }, }, - }, - { - practice: { - contains: parsed.data.q, - mode: "insensitive" as const, + { + practice: { + contains: parsed.data.q, + mode: "insensitive" as const, + }, }, - }, - { - specialty: { - contains: parsed.data.q, - mode: "insensitive" as const, + { + specialty: { + contains: parsed.data.q, + mode: "insensitive" as const, + }, }, - }, - ], - } - : {}), - }, - orderBy: { name: "asc" }, - take: parsed.data.limit ?? 100, - }); + ], + } + : {}), + }, + // Two contacts can share a name; the id keeps the page boundary stable. + orderBy: [{ name: "asc" }, { id: "asc" }], + skip: parsed.data.offset ?? 0, + take, + }), + ); annotate({ action: { name: "practitioner.contact.list", entity_type: "practitioner" }, - meta: { count: rows.length }, + meta: { count: rows.length, truncated: cap.truncated }, }); - return apiSuccess(rows.map(toPractitionerDTO)); + return apiListSuccess(rows.map(toPractitionerDTO), cap); }); // A double-tap on "save" must not mint a second copy of the same practice. diff --git a/src/app/api/symptoms/events/route.ts b/src/app/api/symptoms/events/route.ts index a23e997a4..40fa8b0ec 100644 --- a/src/app/api/symptoms/events/route.ts +++ b/src/app/api/symptoms/events/route.ts @@ -15,6 +15,8 @@ */ import { NextRequest } from "next/server"; +import { listPage } from "@/lib/http/list-query"; + import { prisma } from "@/lib/db"; import { apiSuccess, @@ -65,16 +67,18 @@ export const GET = apiHandler(async (request: NextRequest) => { if (q.from) occurredAt.gte = new Date(q.from); if (q.to) occurredAt.lte = new Date(q.to); - const rows = await prisma.symptomEvent.findMany({ - where: { - userId: user.id, - ...(q.from || q.to ? { occurredAt } : {}), - ...(q.definitionId ? { definitionId: q.definitionId } : {}), - ...(q.episodeId ? { episodeId: q.episodeId } : {}), - }, - orderBy: [{ occurredAt: "desc" }, { id: "desc" }], - take: q.limit, - }); + const { rows, cap } = await listPage(q.limit, (take) => + prisma.symptomEvent.findMany({ + where: { + userId: user.id, + ...(q.from || q.to ? { occurredAt } : {}), + ...(q.definitionId ? { definitionId: q.definitionId } : {}), + ...(q.episodeId ? { episodeId: q.episodeId } : {}), + }, + orderBy: [{ occurredAt: "desc" }, { id: "desc" }], + take, + }), + ); annotate({ action: { name: "symptoms.event.list" }, @@ -84,7 +88,10 @@ export const GET = apiHandler(async (request: NextRequest) => { return apiSuccess({ events: rows.map(toSymptomEventDTO), limit: q.limit, - hasMore: rows.length === q.limit, + // Exact: true only when a row past this page exists. `rows.length === + // limit` said true on a page that was exactly full, and the client then + // asked for a page that held nothing. + hasMore: cap.truncated, }); }); diff --git a/src/app/api/telegram/webhook/__tests__/route.test.ts b/src/app/api/telegram/webhook/__tests__/route.test.ts index 0516e8480..b9ccdb6ab 100644 --- a/src/app/api/telegram/webhook/__tests__/route.test.ts +++ b/src/app/api/telegram/webhook/__tests__/route.test.ts @@ -93,8 +93,8 @@ vi.mock("@/lib/cache/invalidate", () => ({ invalidateUserMedications: vi.fn(), })); -vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ - recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +vi.mock("@/lib/medications/dose-day-refresh", () => ({ + refreshDoseSlot: vi.fn().mockResolvedValue(undefined), })); import { POST, GET } from "../route"; diff --git a/src/app/api/vaccinations/custom/route.ts b/src/app/api/vaccinations/custom/route.ts index dbb15e488..d114d3a3a 100644 --- a/src/app/api/vaccinations/custom/route.ts +++ b/src/app/api/vaccinations/custom/route.ts @@ -31,7 +31,6 @@ import { returnAllZodIssues, safeJson, } from "@/lib/api-response"; -import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { customVaccineCreateSchema } from "@/lib/validations/vaccinations"; import { toCustomVaccineDTO } from "@/lib/vaccinations/dto"; import { @@ -57,16 +56,7 @@ export const GET = apiHandler(async () => { }); export const POST = apiHandler(async (request: NextRequest) => { - const { user, actor } = await requireRecordAuth("write", "profile"); - - // The shared per-account write ceiling, keyed on the ACTOR so a delegate - // spends their own allowance (`checkRecordWriteRateLimit`). - const writeRl = await checkRecordWriteRateLimit(actor.id); - if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429, { - errorCode: "record_write.rate_limited", - }); - } + const { user } = await requireRecordAuth("write", "profile"); const { data: rawBody, error: jsonError } = await safeJson(request, { maxBytes: 8 * 1024, diff --git a/src/app/api/vaccinations/route.ts b/src/app/api/vaccinations/route.ts index f7cda6128..afb938b15 100644 --- a/src/app/api/vaccinations/route.ts +++ b/src/app/api/vaccinations/route.ts @@ -29,8 +29,9 @@ import { returnAllZodIssues, safeJson, } from "@/lib/api-response"; -import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { withIdempotency } from "@/lib/idempotency"; +import { apiListSuccess } from "@/lib/api/list-cap"; +import { listPage } from "@/lib/http/list-query"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { vaccinationCreateSchema, @@ -62,6 +63,7 @@ export const GET = apiHandler(async (request: NextRequest) => { from: params.get("from") ?? undefined, to: params.get("to") ?? undefined, limit: params.get("limit") ?? undefined, + offset: params.get("offset") ?? undefined, }); if (!parsed.success) { return returnAllZodIssues(parsed.error, 422, { @@ -70,29 +72,34 @@ export const GET = apiHandler(async (request: NextRequest) => { } const query = parsed.data; - // Full and bounded rather than paged: an Impfpass is a lifetime document but - // a small one, and the default already covers a long life. + // A lifetime document but a small one: the default page already covers a + // long life, and `meta.truncated` says when it does not. const take = query.limit ?? 300; - const [rows, series] = await Promise.all([ - prisma.vaccinationRecord.findMany({ - where: { - userId: user.id, - deletedAt: null, - ...(query.antigenSlug ? { antigenSlug: query.antigenSlug } : {}), - ...(query.from || query.to - ? { - occurredAt: { - ...(query.from ? { gte: new Date(query.from) } : {}), - ...(query.to ? { lte: new Date(query.to) } : {}), - }, - } - : {}), - }, - orderBy: { occurredAt: "desc" }, - take, - include: VACCINATION_INCLUDE, - }), + const [{ rows, cap }, series] = await Promise.all([ + listPage(take, (overTake) => + prisma.vaccinationRecord.findMany({ + where: { + userId: user.id, + deletedAt: null, + ...(query.antigenSlug ? { antigenSlug: query.antigenSlug } : {}), + ...(query.from || query.to + ? { + occurredAt: { + ...(query.from ? { gte: new Date(query.from) } : {}), + ...(query.to ? { lte: new Date(query.to) } : {}), + }, + } + : {}), + }, + // Two doses on one day are common (a combined appointment); the id + // keeps the page boundary between them stable. + orderBy: [{ occurredAt: "desc" }, { id: "desc" }], + skip: query.offset ?? 0, + take: overTake, + include: VACCINATION_INCLUDE, + }), + ), // Over the WHOLE live set, never over the filtered page: a position is a // count of what came before it, so deriving it from a window would report // the oldest dose in that window as the first one ever given. @@ -135,24 +142,14 @@ export const GET = apiHandler(async (request: NextRequest) => { ), renewals, }; - return apiSuccess(body); + return apiListSuccess(body, cap); }); // A double-tap on "save" must not log the same dose twice. export const POST = apiHandler(withIdempotency<[NextRequest]>(postVaccination)); async function postVaccination(request: NextRequest): Promise { - const { user, actor } = await requireRecordAuth("write", "profile"); - - // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The - // batch siblings have always been capped; the per-record creates a looping - // client hits were not. - const writeRl = await checkRecordWriteRateLimit(actor.id); - if (!writeRl.allowed) { - return apiError("Too many writes, try again later", 429, { - errorCode: "record_write.rate_limited", - }); - } + const { user } = await requireRecordAuth("write", "profile"); const { data: rawBody, error: jsonError } = await safeJson(request, { maxBytes: 32 * 1024, diff --git a/src/app/api/workouts/batch/route.ts b/src/app/api/workouts/batch/route.ts index c25beccca..37ca6dfaf 100644 --- a/src/app/api/workouts/batch/route.ts +++ b/src/app/api/workouts/batch/route.ts @@ -239,7 +239,12 @@ async function postBatch(request: NextRequest): Promise { // native login mints. The only route that names it. `requireAuth` refuses // any acting-account carrier for every credential shape, so the scoped token // is confined to its owner's own record without an arm of its own here. - const auth = await requireAuth(WORKOUTS_WRITE_SCOPE); + // Metered by this route's own bulk bucket below, not the shared per-account + // write bucket: the native client syncs through here in bursts (see + // `meterAccountWrite`). + const auth = await requireAuth(WORKOUTS_WRITE_SCOPE, { + writeBucket: "own", + }); const { user } = auth; // A scoped credential is a bridge, not the native client: the source of diff --git a/src/app/coach/page-client.tsx b/src/app/coach/page-client.tsx index 0e1d404b1..8c08143a6 100644 --- a/src/app/coach/page-client.tsx +++ b/src/app/coach/page-client.tsx @@ -14,7 +14,8 @@ import { useAuth } from "@/hooks/use-auth"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; -import { coachScopeSourceSchema } from "@/lib/ai/coach/types"; +import { COACH_SCOPE_SOURCES } from "@/lib/ai/coach/scope-sources"; +import type { CoachScopeSource } from "@/lib/ai/coach/types"; /** * v1.12.0 (Coach v2 #6) — full-page Coach conversation. @@ -117,12 +118,13 @@ function CoachPageBody() { // chat (`?doc=`) keeps its own established scope. const freshChat = deepLinkedId === null && seedDocumentId === null; const rawScope = freshChat ? searchParams.get("scope") : null; - const scopeResult = rawScope - ? coachScopeSourceSchema.safeParse(rawScope) - : null; - const launchScope: CoachLaunchScope | null = scopeResult?.success - ? { metric: scopeResult.data } - : null; + // A closed-set check, not a schema parse: the route stays free of the + // schema library for one string comparison. + const launchScope: CoachLaunchScope | null = + rawScope !== null && + (COACH_SCOPE_SOURCES as readonly string[]).includes(rawScope) + ? { metric: rawScope as CoachScopeSource } + : null; const seedPrefill = freshChat ? searchParams.get("ask") : null; // Read once: the panel consumes it on mount, then the URL loses it. diff --git a/src/app/globals.css b/src/app/globals.css index 8006ffce5..515f569a7 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -232,7 +232,7 @@ /* * v1.28.17 — the sleep-stage IN_BED fill. Distinct from `--dracula-comment` * above (an orphaned token — no `@theme` utility was ever generated for - * it, per the `confidence-badge.ts` note — and its `#9aa3b3` value never + * it — and its `#9aa3b3` value never * matched the classic Dracula "Comment" blue-grey this chart actually * paints). Minted here instead of retuning the unrelated token, so this * default carries the exact hex the stacked bar hardcoded before diff --git a/src/app/insights/__tests__/assessment-spine-order.test.ts b/src/app/insights/__tests__/assessment-spine-order.test.ts index 49f601778..e71f3cabc 100644 --- a/src/app/insights/__tests__/assessment-spine-order.test.ts +++ b/src/app/insights/__tests__/assessment-spine-order.test.ts @@ -181,13 +181,11 @@ describe("assessment presentation contract", () => { "utf8", ); - expect(cardSource).toContain( - 'className="animate-insight-in gap-2 py-3 md:py-4"', - ); + expect(cardSource).toContain('density="compact"'); + expect(cardSource).toContain('className="animate-insight-in"'); expect(cardSource).toContain('className="text-foreground text-sm"'); - expect(moodSource).toContain( - 'className="animate-insight-in gap-2 py-3 md:py-4"', - ); + expect(moodSource).toContain('density="compact"'); + expect(moodSource).toContain('className="animate-insight-in"'); expect(moodSource).toContain('data-slot="insight-assessment"'); expect(moodBodySource).toContain( 'className="text-foreground mb-2 text-sm"', diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index a6772ce9c..43186b0a4 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -204,7 +204,7 @@ export default function InsightsBlutdruckPage() { // edge, ~16–24 px left of every neighbouring card's text) — and // foreground, not muted: multi-sentence explainer prose is // content, muted stays reserved for meta. - +

{t("insights.subPage.bpDerived.pulsePressure", { diff --git a/src/app/insights/catalog/page.tsx b/src/app/insights/catalog/page.tsx index 88425d5f7..805a7bdfe 100644 --- a/src/app/insights/catalog/page.tsx +++ b/src/app/insights/catalog/page.tsx @@ -351,11 +351,7 @@ export default function MetricCatalogPage() { // it ever probes for rows, so a module-off account never saw // the in-context enable CTA; this row is the fix). return ( - + @@ -18,21 +19,25 @@ export default function InsightsLoading() { {/* Chart card */} -

- - -
+ + + + + + {/* Assessment card */} -
-
- - -
- - - -
+ + +
+ + +
+ + + +
+

); } diff --git a/src/app/insights/medications/page.tsx b/src/app/insights/medications/page.tsx index 08fccaf84..d017986c9 100644 --- a/src/app/insights/medications/page.tsx +++ b/src/app/insights/medications/page.tsx @@ -248,7 +248,7 @@ export default function InsightsMedikamentePage() { med.categoryLabel, ); return ( - +
@@ -394,11 +394,7 @@ function MedicationComplianceCalendar({ } if (!data?.dailyCompliance) { - return ( -
- {t("insights.complianceNoData")} -
- ); + return ; } return ( diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index 81c888314..f99f89681 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { ArrowRight, Gauge, Heart } from "lucide-react"; +import { Gauge, Heart } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; @@ -19,9 +19,9 @@ import { MeasurementDiversityNudge } from "@/components/insights/measurement-div import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; import { EcgCrossLink } from "@/components/insights/ecg-cross-link"; import { SubPageShell } from "@/components/insights/sub-page-shell"; -import { TileHeader } from "@/components/insights/tile-header"; import dynamic from "next/dynamic"; import { ChartSkeleton } from "@/components/charts/chart-skeleton"; +import { CrossLinkCard } from "@/components/insights/cross-link-card"; // Load the intraday area chart through the shared chart-runtime boundary (the // one recharts chunk), not as a direct import — a direct `from "recharts"` @@ -199,27 +199,17 @@ export default function InsightsPulsPage() { the full chart plus the plain-language assessment — rather than duplicating a second, divergent VO₂ max view here. - v1.12.8 — the card now leads with the canonical `` so the - `Gauge` glyph reads in the foreground colour at the same size and - position as every other tile header, with the body + CTA beneath. */} + The card is the shared `CrossLinkCard`: the canonical `TileHeader` + (foreground `Gauge` glyph), the body and the CTA beneath. */} {hasVo2 ? ( - - - - {t("insights.vo2Max.cardioLinkBody")} - - - {t("insights.vo2Max.cardioLinkCta")} - - + slot="vo2-cardio-link" + icon={Gauge} + title={t("insights.vo2Max.cardioLinkTitle")} + body={t("insights.vo2Max.cardioLinkBody")} + cta={t("insights.vo2Max.cardioLinkCta")} + /> ) : null} ); diff --git a/src/app/insights/workouts/page-client.tsx b/src/app/insights/workouts/page-client.tsx index 0cf9cdd60..c307d65af 100644 --- a/src/app/insights/workouts/page-client.tsx +++ b/src/app/insights/workouts/page-client.tsx @@ -20,6 +20,7 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { canLogWorkout } from "@/lib/workouts/manual-entry"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { translateOrNull } from "@/lib/i18n/translate-code"; +import { EmptyState } from "@/components/ui/empty-state"; /** * v1.4.32 — `/insights/workouts`. @@ -187,12 +188,13 @@ export default function InsightsWorkoutsPageClient() { /> ) : ( -

- {t("insights.workouts.filterEmpty")} -

+ /> )} )} diff --git a/src/app/mood/page-client.tsx b/src/app/mood/page-client.tsx index c2a24f008..a5cf60065 100644 --- a/src/app/mood/page-client.tsx +++ b/src/app/mood/page-client.tsx @@ -18,6 +18,7 @@ import { ArrowRight, Plus, Wrench } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import { useEffect } from "react"; import { useTranslations } from "@/lib/i18n/context"; +import { InlineAction } from "@/components/ui/inline-action"; export default function MoodPageClient() { const { user, isAuthenticated, isLoading } = useAuth(); @@ -197,14 +198,12 @@ export default function MoodPageClient() { here (module-gated — no nav change) rather than a merge: capture vs. screener stay two defensible surfaces, just cross-linked. */} {user?.modules?.mentalHealth === true ? ( - - {t("mood.mentalWellbeingLink")} -
); diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index 7ce11570b..4c14bd9e3 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -22,6 +22,13 @@ import { } from "@/lib/notifications/event-labels"; import { queryKeys } from "@/lib/query-keys"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; interface ChannelInfo { id: string; @@ -309,17 +316,16 @@ export default function NotificationsPage() { const nameKey = eventTranslationKey(eventType); const descKey = eventDescriptionTranslationKey(eventType); return ( -
-
-
{t(nameKey)}
-
+ + + + {t(nameKey)} + + {t(descKey)} -
-
-
+ + + {activeChannels.map((ch) => { const enabled = isEnabled(ch.id, eventType); const disabled = !ch.enabled; @@ -355,8 +361,8 @@ export default function NotificationsPage() {
); })} -
-
+ + ); })}
diff --git a/src/components/admin/ai-quality-section.tsx b/src/components/admin/ai-quality-section.tsx index a4bc7ee67..97ee96201 100644 --- a/src/components/admin/ai-quality-section.tsx +++ b/src/components/admin/ai-quality-section.tsx @@ -28,6 +28,7 @@ import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; import { helpfulRateColour } from "./_shared"; +import { EmptyState } from "@/components/ui/empty-state"; interface FeedbackBucket { severity: string; @@ -110,9 +111,11 @@ export function AiQualitySection() { onRetry={() => void query.refetch()} /> ) : !hasData ? ( -

- {t("admin.aiQuality.empty")} -

+ ) : (
diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 16b6e5ed4..2f3ab6759 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -843,7 +843,7 @@ async function uploadOnce(file: File) { } export function BackupsSection() { - const { t } = useTranslations(); + const { t, locale } = useTranslations(); const fmt = useFormatters(); const queryClient = useQueryClient(); @@ -1327,7 +1327,7 @@ export function BackupsSection() {
- {formatBytes(row.sizeBytes, fmt)} + {formatBytes(row.sizeBytes, locale)} {fmt.dateTime(row.createdAt)} @@ -1394,7 +1394,7 @@ export function BackupsSection() {

{t("admin.section.backups.colSize")}:{" "} - {formatBytes(row.sizeBytes, fmt)} ·{" "} + {formatBytes(row.sizeBytes, locale)} ·{" "} {fmt.dateTime(row.createdAt)}

diff --git a/src/components/admin/central-codex-section.tsx b/src/components/admin/central-codex-section.tsx index 7ae0ffb97..c9d9e1734 100644 --- a/src/components/admin/central-codex-section.tsx +++ b/src/components/admin/central-codex-section.tsx @@ -36,6 +36,7 @@ import { invalidateKeys, queryKeys, } from "@/lib/query-keys"; +import { InlineAction } from "@/components/ui/inline-action"; interface CentralCodexStatus { status: string; @@ -228,15 +229,15 @@ export function CentralCodexSection() { {t("settings.ai.deviceCodeStep2")}
{deviceCode.userCode} - +
  • {t("settings.ai.deviceCodeStep3")}
  • diff --git a/src/components/admin/coach-feedback-section.tsx b/src/components/admin/coach-feedback-section.tsx index d62a4f8ba..09ac117bc 100644 --- a/src/components/admin/coach-feedback-section.tsx +++ b/src/components/admin/coach-feedback-section.tsx @@ -28,6 +28,7 @@ import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; import { helpfulRateColour } from "./_shared"; +import { EmptyState } from "@/components/ui/empty-state"; interface CoachFeedbackBucket { promptVersion: string; @@ -105,9 +106,11 @@ export function CoachFeedbackSection() { onRetry={() => void query.refetch()} /> ) : !hasData ? ( -

    - {t("admin.coachFeedback.empty")} -

    + ) : (
    diff --git a/src/components/admin/encryption-section.tsx b/src/components/admin/encryption-section.tsx index 78230e0cf..4ba20e22b 100644 --- a/src/components/admin/encryption-section.tsx +++ b/src/components/admin/encryption-section.tsx @@ -53,6 +53,7 @@ import { import { KeyBackupCard } from "./key-backup-card"; import { useKeyBackupStatus } from "./use-key-backup-status"; import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { EmptyState } from "@/components/ui/empty-state"; interface ColumnScan { model: string; @@ -229,11 +230,11 @@ export function EncryptionSection() { /> {/* "Safe to drop the legacy key" needs a legacy key to drop. With a @@ -414,6 +415,7 @@ export function ColumnCoverage({ initiallyOpen?: boolean; }) { const { t } = useTranslations(); + const fmt = useFormatters(); const [open, setOpen] = useState(initiallyOpen); const [showAll, setShowAll] = useState(false); const withRows = columns.filter((c) => c.total > 0); @@ -445,9 +447,11 @@ export function ColumnCoverage({ {open ? ( <> {shown.length === 0 ? ( -

    - {t("admin.section.encryption.columnsNoneWithRows")} -

    + ) : ( /* Focusable, named scroll region: on a phone the table scrolls sideways, and a keyboard user has to be able to reach it @@ -484,16 +488,12 @@ export function ColumnCoverage({
    - - + +
    {c.model}.{c.field} - {c.total.toLocaleString()} - - {active.toLocaleString()} - {fmt.integer(c.total)}{fmt.integer(active)} {stale > 0 ? ( - {stale.toLocaleString()} + {fmt.integer(stale)} ) : ( 0 diff --git a/src/components/admin/general-settings-section.tsx b/src/components/admin/general-settings-section.tsx index 4182d6e3b..d98b9f0e1 100644 --- a/src/components/admin/general-settings-section.tsx +++ b/src/components/admin/general-settings-section.tsx @@ -54,7 +54,8 @@ export function GeneralSettingsSection() { : ""; const storedQuotaGb = settings?.documentQuotaBytes != null - ? String(Math.round((settings.documentQuotaBytes / GIB) * 10) / 10) + ? // eslint-disable-next-line healthlog/no-unlocalised-decimal -- value of a type="number" input, which only takes a full stop + String(Math.round((settings.documentQuotaBytes / GIB) * 10) / 10) : ""; const commitMaxFile = () => { diff --git a/src/components/admin/host-metrics-chart.tsx b/src/components/admin/host-metrics-chart.tsx index 20987aa64..702b779fe 100644 --- a/src/components/admin/host-metrics-chart.tsx +++ b/src/components/admin/host-metrics-chart.tsx @@ -47,6 +47,8 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { apiGet } from "@/lib/api/api-fetch"; import { readStoredTimezone } from "@/lib/timezone-mirror"; +import { resolveIntlLocale } from "@/lib/format-locale"; +import { EmptyState } from "@/components/ui/empty-state"; interface HostMetricsApiSample { capturedAt: string; @@ -89,7 +91,10 @@ const COLOR_DISK = "var(--dracula-purple)"; * + the absolute peak BPS used to scale `diskBusyPercent`. Kept as a * separate export so the test suite doesn't need to render Recharts. */ -export function buildChartRows(samples: HostMetricsApiSample[]): { +export function buildChartRows( + samples: HostMetricsApiSample[], + intlLocale?: string, +): { rows: ChartRow[]; peakDiskBps: number; hasDiskData: boolean; @@ -104,7 +109,7 @@ export function buildChartRows(samples: HostMetricsApiSample[]): { // samples), and an operator reading their own instance should see the same // clock here as on every other timestamp in the admin area — a spike at // "14:20" must not be 14:20 here and 15:20 two screens away. - const formatter = new Intl.DateTimeFormat(undefined, { + const formatter = new Intl.DateTimeFormat(intlLocale ?? "en-US", { hour: "2-digit", minute: "2-digit", hour12: false, @@ -141,7 +146,7 @@ export function buildChartRows(samples: HostMetricsApiSample[]): { } export function HostMetricsChart() { - const { t } = useTranslations(); + const { t, locale } = useTranslations(); const fmt = useFormatters(); const { data, isLoading, isError, error, refetch } = useQuery({ @@ -156,8 +161,8 @@ export function HostMetricsChart() { }); const { rows, peakDiskBps, hasDiskData } = useMemo( - () => buildChartRows(data?.samples ?? []), - [data?.samples], + () => buildChartRows(data?.samples ?? [], resolveIntlLocale(locale)), + [data?.samples, locale], ); // Total physical memory (latest sample) — lets the tooltip pair the @@ -223,9 +228,11 @@ export function HostMetricsChart() { } /> -

    - {t("admin.hostMetrics.empty")} -

    + ); } diff --git a/src/components/admin/login-overview-section.tsx b/src/components/admin/login-overview-section.tsx index a6893ce0a..a20d6578d 100644 --- a/src/components/admin/login-overview-section.tsx +++ b/src/components/admin/login-overview-section.tsx @@ -16,6 +16,7 @@ import { XCircle, } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Segmented } from "@/components/ui/segmented"; import { EmptyState } from "@/components/ui/empty-state"; import { Input } from "@/components/ui/input"; import { @@ -231,26 +232,17 @@ export function LoginOverviewSection() {
    {/* Quick-filter pills. Failed-only is the most common admin "is something wrong" question — keep the one-tap shortcut. */} -
    - - -
    + {/* Detailed filter row */}
    diff --git a/src/components/admin/measurement-maintenance-card.tsx b/src/components/admin/measurement-maintenance-card.tsx index 6109f5a89..3569c0e64 100644 --- a/src/components/admin/measurement-maintenance-card.tsx +++ b/src/components/admin/measurement-maintenance-card.tsx @@ -45,20 +45,8 @@ 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 { t, locale } = useTranslations(); const fmt = useFormatters(); const queryClient = useQueryClient(); @@ -132,11 +120,11 @@ export function MeasurementMaintenanceCard() {
    ) : null} diff --git a/src/components/admin/module-availability-section.tsx b/src/components/admin/module-availability-section.tsx index df5442811..f57122c64 100644 --- a/src/components/admin/module-availability-section.tsx +++ b/src/components/admin/module-availability-section.tsx @@ -23,6 +23,7 @@ import { import type { ModuleKey } from "@/lib/modules/registry"; import { SettingsToggle } from "./_shared"; import { apiGet, apiPatch } from "@/lib/api/api-fetch"; +import { InlineAction } from "@/components/ui/inline-action"; /** * v1.18.0 — operator-side panel for server-wide module availability, the @@ -127,12 +128,11 @@ export function ModuleAvailabilitySection() { onCheckedChange={() => {}} disabled /> - - {t("admin.section.coach.title")} - + + + {t("admin.section.coach.title")} + +
    ); } diff --git a/src/components/admin/offhost-backups-section.tsx b/src/components/admin/offhost-backups-section.tsx index 99ffb6af2..3e65f6402 100644 --- a/src/components/admin/offhost-backups-section.tsx +++ b/src/components/admin/offhost-backups-section.tsx @@ -84,7 +84,7 @@ function freshnessLabel( } function AccountRow({ row }: { row: OffhostAccountRow }) { - const { t } = useTranslations(); + const { t, locale } = useTranslations(); const fmt = useFormatters(); return ( @@ -128,7 +128,7 @@ function AccountRow({ row }: { row: OffhostAccountRow }) { }) : `${fmt.dateTime(row.lastSuccessAt)} · ${formatBytes( row.sizeBytes, - fmt, + locale, )} · ${t("admin.section.backups.offhost.age", { hours: row.ageHours ?? 0, })}`} diff --git a/src/components/admin/provider-health-section.tsx b/src/components/admin/provider-health-section.tsx index 3032ee8ef..4df470bea 100644 --- a/src/components/admin/provider-health-section.tsx +++ b/src/components/admin/provider-health-section.tsx @@ -11,6 +11,7 @@ import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; import { formatDateTime } from "@/lib/format"; import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { EmptyState } from "@/components/ui/empty-state"; /** * v1.37.31 — the delivery-health readout the `provider_health` ledger never @@ -143,9 +144,11 @@ export function ProviderHealthSection() { onRetry={() => void refetch()} /> ) : providers.length === 0 ? ( -

    - {t("admin.providerHealth.empty")} -

    + ) : (
      {providers.map((p) => ( diff --git a/src/components/admin/recent-audit-preview.tsx b/src/components/admin/recent-audit-preview.tsx index cab8589b7..0fb5ac6e4 100644 --- a/src/components/admin/recent-audit-preview.tsx +++ b/src/components/admin/recent-audit-preview.tsx @@ -27,6 +27,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { type AdminAuditEntry, useAuthActionLabels } from "./_shared"; import { apiGet } from "@/lib/api/api-fetch"; +import { InlineAction } from "@/components/ui/inline-action"; const PREVIEW_LIMIT = 10; @@ -61,13 +62,12 @@ export function RecentAuditPreview() { titleId="admin-overview-audit-heading" title={t("admin.overview.auditTitle")} status={ - - {t("admin.overview.auditViewAll")} -
    {filteredUsers ? ( diff --git a/src/components/ai/ai-consent-request.tsx b/src/components/ai/ai-consent-request.tsx index 81d5d24fe..09f26ba45 100644 --- a/src/components/ai/ai-consent-request.tsx +++ b/src/components/ai/ai-consent-request.tsx @@ -138,7 +138,7 @@ export function AiConsentRequest({ }; return ( - + @@ -326,7 +326,7 @@ function ConditionRow({ const site = bodySiteText(t, condition.bodySite, condition.laterality); return ( diff --git a/src/components/charts/__tests__/trend-card-baseline-alignment.test.tsx b/src/components/charts/__tests__/trend-card-baseline-alignment.test.tsx index 3cb247c7b..e14a1b2af 100644 --- a/src/components/charts/__tests__/trend-card-baseline-alignment.test.tsx +++ b/src/components/charts/__tests__/trend-card-baseline-alignment.test.tsx @@ -11,10 +11,13 @@ import { I18nProvider } from "@/lib/i18n/context"; * Maintainer directive 2026-05-14 named three issues with the 5-tile top row: * * 1. Headings wrapped to two lines at narrow viewports (e.g. "Blood - * Pressure" on Pixel 5 / 393 px / 2-col grid). Locked the heading - * row to a deterministic `h-5` with `whitespace-nowrap` so even an - * unexpectedly long string never breaks the baseline of the value - * row below. + * Pressure" on Pixel 5 / 393 px / 2-col grid), moving the value row. + * The heading row is a deterministic height so the value row keeps + * its baseline. It reserves TWO lines (`h-10`, v1.43.8): pinned to one + * line it could only keep that promise by cutting the label, and long + * German compounds and glucose contexts ended in an ellipsis on every + * dashboard. A label wraps (hyphenated) inside the reserve; the rare + * third line clamps with the full text in `title`. * 2. Trend arrow rendered as a wrapping flex child after the unit — * sometimes wrapped to its own line, sometimes missing entirely * when slope30 was null. Now sits in a fixed `h-4 w-4` slot with @@ -27,8 +30,8 @@ import { I18nProvider } from "@/lib/i18n/context"; * tile in the strip to share the same baseline. * * These tests pin the load-bearing class names so a future refactor - * that drops `h-5` / `items-baseline` / `whitespace-nowrap` triggers a - * red CI before it reaches the dashboard. + * that drops `h-10` / `items-baseline` / `line-clamp-2` triggers a red CI + * before it reaches the dashboard. */ const RISING = { slope: 0.5, direction: "up" as const, confidence: 0.8 }; @@ -53,13 +56,14 @@ describe(" W20a baseline alignment", () => { />, ); - // h-5 is the load-bearing class — keeps the value row below at the - // same y-coordinate regardless of the label string's actual width. - expect(html).toContain("h-5"); - expect(html).toContain('data-slot="trend-card-label"'); + // h-10 is the load-bearing class — keeps the value row below at the + // same y-coordinate whether the label takes one line or two. + expect(html).toMatch( + /class="flex h-10 [^"]*"[^>]*>]*data-slot="trend-card-label"/, + ); }); - it("forbids wrapping on the heading row even with a long label", () => { + it("wraps a long label inside the reserve instead of cutting it", () => { const html = render( W20a baseline alignment", () => { />, ); - expect(html).toContain("whitespace-nowrap"); - expect(html).toContain("truncate"); + const label = html.match( + / { @@ -166,9 +181,10 @@ describe(" W20a baseline alignment", () => { />, ); - // Same heading-row height contract — `h-5` + `whitespace-nowrap`. - expect(html).toContain("h-5"); - expect(html).toContain("whitespace-nowrap"); + // Same heading-row height contract — two reserved label lines. + expect(html).toMatch( + /class="flex h-10 [^"]*"[^>]*>]*data-slot="trend-card-label"/, + ); // Same value-row baseline contract — `items-baseline` + `text-3xl` // + `leading-none`. Identical font geometry across all five tiles // means the digits land at the same y-coordinate inside the grid. diff --git a/src/components/charts/chart-range-tabs.tsx b/src/components/charts/chart-range-tabs.tsx index 0f3ba18b5..4c8528606 100644 --- a/src/components/charts/chart-range-tabs.tsx +++ b/src/components/charts/chart-range-tabs.tsx @@ -1,6 +1,6 @@ "use client"; -import { Button } from "@/components/ui/button"; +import { Segmented } from "@/components/ui/segmented"; import type { ChartRangePoints } from "@/lib/dashboard-layout"; import { useTranslations } from "@/lib/i18n/context"; @@ -50,22 +50,17 @@ export function ChartRangeTabs({ }) { const { t } = useTranslations(); return ( - <> - {CHART_RANGE_TABS.map((r) => ( - - ))} - + "chart-range-tab"} + value={String(value)} + onChange={(next) => onChange(Number(next) as ChartRangePoints)} + options={CHART_RANGE_TABS.map((r) => ({ + value: String(r.days), + label: t(r.labelKey), + title: t(r.titleKey), + }))} + /> ); } diff --git a/src/components/charts/medication-compliance-chart.tsx b/src/components/charts/medication-compliance-chart.tsx index fc10e8a23..2abcb1b26 100644 --- a/src/components/charts/medication-compliance-chart.tsx +++ b/src/components/charts/medication-compliance-chart.tsx @@ -573,9 +573,7 @@ export function MedicationComplianceChart({ } /> ) : !hasData ? ( -
    - {t("charts.noData")} -
    + ) : chartData.length < 3 ? ( // v1.4.16 — sparse-data placeholder consistent with the // BP/weight/pulse/mood charts. diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx index 226d273e6..04c26438d 100644 --- a/src/components/charts/mood-chart.tsx +++ b/src/components/charts/mood-chart.tsx @@ -15,12 +15,15 @@ import { ReferenceLine, } from "recharts"; import { useEffect, useState, useMemo, useRef } from "react"; -import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { Card, CardHeader, CardContent } from "@/components/ui/card"; import { TagChip } from "@/components/ui/tag-chip"; import { TileHeader } from "@/components/insights/tile-header"; -import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context"; +import { + useDateFormatPreference, + useFormatters, + useTranslations, +} from "@/lib/i18n/context"; import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label"; import { readStoredTimezone } from "@/lib/timezone-mirror"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; @@ -39,6 +42,7 @@ import { useChartDayLinks, } from "@/components/day/chart-day-links"; import { ChartEmptyState } from "./chart-empty-state"; +import { ChartRangeTabs } from "./chart-range-tabs"; import { ChartErrorState } from "./chart-error-state"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { shiftDailySeriesForward } from "@/lib/charts/comparison-shift"; @@ -123,32 +127,8 @@ interface MoodChartProps { // --- Constants --- // The range tabs select a CALENDAR-DAY window ending now, matching -// `health-chart.tsx` (see the note there). Until v1.37.29 this chart -// sliced the last N LOGGED DAYS out of the whole history instead — the -// same "7" tab meant seven calendar days on a weight chart and seven -// logged days (possibly weeks apart) on the mood chart one card over. -const TIME_RANGES_KEYS = [ - { - labelKey: "charts.days7Label", - days: 7, - titleKey: "charts.days7Title", - }, - { - labelKey: "charts.days30Label", - days: 30, - titleKey: "charts.days30Title", - }, - { - labelKey: "charts.days90Label", - days: 90, - titleKey: "charts.days90Title", - }, - { - labelKey: "charts.daysAllLabel", - days: 0, - titleKey: "charts.daysAllTitle", - }, -] as const; +// `health-chart.tsx`, through the same `ChartRangeTabs` every chart drawn in +// days carries. // MOOD_LABELS built dynamically via t() in the component @@ -299,6 +279,7 @@ export function MoodChart({ }: MoodChartProps) { const { isAuthenticated } = useAuth(); const { t, locale } = useTranslations(); + const fmt = useFormatters(); // Issue #490 — x-axis tick + tooltip date labels. Every timestamp this // chart formats encodes a CALENDAR DAY (noon-UTC of a server day key, // or a `bucketTimeSeries` Berlin-day UTC-midnight bucket start), so @@ -618,9 +599,9 @@ export function MoodChart({ }; const formatTooltipValue = (value: number): string => { - const rounded = Math.round(value * 10) / 10; + const rounded = fmt.number(Math.round(value * 10) / 10); const label = moodLabels[Math.round(value)]; - return label ? `${rounded} (${label})` : String(rounded); + return label ? `${rounded} (${label})` : rounded; }; // v1.4.16 — personal mood baseline (median over visible window). @@ -681,7 +662,7 @@ export function MoodChart({ if (scores.length === 0) { return t("charts.a11y.noData", { metric }); } - const fmtScore = (v: number) => String(Math.round(v * 10) / 10); + const fmtScore = (v: number) => fmt.number(Math.round(v * 10) / 10); const minVal = Math.min(...scores); const maxVal = Math.max(...scores); const latestVal = scores[scores.length - 1]; @@ -727,9 +708,7 @@ export function MoodChart({ height={CHART_HEIGHT_PX} /> ) : !chartData?.length ? ( -
    - {t("charts.noData")} -
    + ) : chartData.length < 3 ? ( // v1.4.16 — sparse-data placeholder consistent with the // BP/weight/pulse charts. Height tracks the chart strip's @@ -938,7 +917,7 @@ export function MoodChart({ delta = t("charts.deltaUnchanged"); } else { const sign = diff > 0 ? "+" : "−"; - const formatted = `${sign}${Math.abs(diff).toFixed(1)}`; + const formatted = `${sign}${fmt.number(Math.abs(diff), 1)}`; delta = t( effectiveCompareBaseline === "lastMonth" ? "comparison.deltaVs.lastMonth" @@ -951,7 +930,7 @@ export function MoodChart({ delta = t("charts.deltaUnchanged"); } else { const sign = diff > 0 ? "+" : "−"; - const formatted = `${sign}${Math.abs(diff).toFixed(1)}`; + const formatted = `${sign}${fmt.number(Math.abs(diff), 1)}`; delta = t("charts.deltaVsBaseline").replace( "{delta}", formatted, @@ -1123,31 +1102,21 @@ export function MoodChart({ className="flex flex-nowrap items-center justify-end gap-1 self-end sm:self-auto" data-slot="chart-header-controls" > - {TIME_RANGES_KEYS.map((r) => ( - - ))} + { + setRangeHydrated(true); + setRangePoints(days); + // v1.30.1 — persist the pick per chart, same + // model as the overlay toggles. + if (chartKey && !windowOverride) { + overlayPrefs.setPrefs({ + ...overlayPrefs.prefs, + rangePoints: days, + }); + } + }} + /> {/* v1.4.18 — overlay-controls dropdown next to the range tabs. Only painted when the chart is bound to a persistent chartKey; ad-hoc usages (/insights mood diff --git a/src/components/charts/mood-heatmap.tsx b/src/components/charts/mood-heatmap.tsx index 06475692a..cb3d86328 100644 --- a/src/components/charts/mood-heatmap.tsx +++ b/src/components/charts/mood-heatmap.tsx @@ -6,7 +6,11 @@ import { useCalendarDate } from "@/hooks/use-calendar-date"; import { dateOnlyKey } from "@/lib/tz/date-only"; import { ChartDataTable } from "./chart-data-table"; import { useState, useMemo, useEffect, useRef } from "react"; -import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context"; +import { + useDisplayTimezone, + useFormatters, + useTranslations, +} from "@/lib/i18n/context"; import { heatmapDays } from "@/lib/charts/heatmap-days"; import { moodLabelKeyForScore } from "@/lib/mood/labels"; @@ -144,6 +148,7 @@ export function MoodHeatmap({ stretch = false, }: MoodHeatmapProps) { const { t } = useTranslations(); + const fmt = useFormatters(); const timeZone = useDisplayTimezone(); // Day keys are calendar dates ("YYYY-MM-DD"), read as such in every zone. // A noon-UTC instant formatted in the reader's zone was a day late from @@ -274,7 +279,7 @@ export function MoodHeatmap({ } const labelKey = moodLabelKeyForScore(Math.round(cell.cell.score)); const moodLabel = labelKey ? t(labelKey) : ""; - return `${formatDay(cell.dateKey)}: ${cell.cell.score.toFixed(1)}${moodLabel ? ` · ${moodLabel}` : ""}`; + return `${formatDay(cell.dateKey)}: ${fmt.number(cell.cell.score, 1)}${moodLabel ? ` · ${moodLabel}` : ""}`; }; // Only the logged days go into the data table; the empty-tinted cells // are already covered by the aggregate summary on the SVG, so a rotor @@ -420,8 +425,8 @@ export function MoodHeatmap({ formatValue={(value) => { const labelKey = moodLabelKeyForScore(Math.round(value)); return labelKey - ? `${value.toFixed(1)} · ${t(labelKey)}` - : value.toFixed(1); + ? `${fmt.number(value, 1)} · ${t(labelKey)}` + : fmt.number(value, 1); }} formatDate={(date) => formatDay(dateOnlyKey(date))} bucket="day" diff --git a/src/components/charts/trend-card-skeleton.tsx b/src/components/charts/trend-card-skeleton.tsx index 67f75d5fe..97bd8e5d6 100644 --- a/src/components/charts/trend-card-skeleton.tsx +++ b/src/components/charts/trend-card-skeleton.tsx @@ -1,5 +1,6 @@ import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; +import { TrendTile } from "@/components/ui/trend-tile"; /** * v1.16.0 — structured loading silhouette for one dashboard trend tile. @@ -23,20 +24,18 @@ import { cn } from "@/lib/utils"; */ export function TrendCardSkeleton({ className }: { className?: string }) { return ( - + ); } diff --git a/src/components/charts/trend-card.tsx b/src/components/charts/trend-card.tsx index 58c9800e7..d131706ac 100644 --- a/src/components/charts/trend-card.tsx +++ b/src/components/charts/trend-card.tsx @@ -16,6 +16,7 @@ import { sentimentColorClass, type TrendDirectionSentiment, } from "@/lib/insights/trend-sentiment"; +import { TrendTile } from "@/components/ui/trend-tile"; interface SecondaryMetric { /** Sub-value latest reading (e.g. diastolic when latest is systolic). */ @@ -204,21 +205,28 @@ export function TrendCard({ }; return ( -
    - {/* v1.4.25 — single-line discipline + deterministic height for - the heading row so the value row below sits at the same baseline - across every tile. The label is locale-abbreviated upstream (see - `dashboard.*Short` keys) so `truncate` is a layout safeguard - rather than the primary defence against wraps. `h-5` matches - `text-xs leading-5` ≈ 20 px; identical across every tile. */} -
    + + {/* The heading row has a deterministic height so the value row below + sits at the same baseline across every tile in the strip. It + reserves two lines, not one: a one-line row could only keep that + promise by cutting the label, and the label is the only thing that + names the figure. An uppercase German compound + ("HERZFREQUENZVARIABILITÄT") or a glucose context ("GLUKOSE, VOR + DEM SCHLAFEN") does not fit a strip tile on one line in any width + the grid hands out, and ended in an ellipsis. `hyphens-auto` splits + a compound at a syllable (driven by the document's `lang`), + `break-words` is the floor, and the rare label that needs a third + line clamps with its full text in `title`. `h-10` is two + `text-xs leading-5` lines; identical across every tile. */} +
    {label} - +
    {/* v1.4.25 — baseline-aligned value row. The headline value, unit, and trend arrow share a single flex line with @@ -465,6 +473,6 @@ export function TrendCard({ sub-rows. */}
    -
    + ); } diff --git a/src/components/charts/workout-elevation-chart.tsx b/src/components/charts/workout-elevation-chart.tsx index dbf0382fc..251cd6df5 100644 --- a/src/components/charts/workout-elevation-chart.tsx +++ b/src/components/charts/workout-elevation-chart.tsx @@ -10,7 +10,7 @@ import { ResponsiveContainer, } from "recharts"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { useUnitDisplay } from "@/hooks/use-unit-display"; import { applyDisplayTransform, @@ -35,6 +35,7 @@ export function WorkoutElevationChart({ points: WorkoutElevationPoint[]; }) { const { t } = useTranslations(); + const fmt = useFormatters(); const { preference } = useUnitDisplay(); // Distance along the route in km or mi, altitude in m or ft — the same // registry the stat tiles read, so the axis and the tiles agree. @@ -74,7 +75,7 @@ export function WorkoutElevationChart({ type="number" domain={[0, "dataMax"]} tick={{ fontSize: 10, fill: "var(--muted-foreground)" }} - tickFormatter={(v) => Number(v).toFixed(1)} + tickFormatter={(v) => fmt.number(Number(v), 1)} tickLine={false} axisLine={false} /> @@ -94,7 +95,7 @@ export function WorkoutElevationChart({ fontSize: "0.875rem", }} labelFormatter={(d) => - `${Number(d).toFixed(2)} ${distance.displayUnit}` + `${fmt.number(Number(d), 2)} ${distance.displayUnit}` } formatter={(value) => [ `${Math.round(Number(value))} ${elevation.displayUnit}`, diff --git a/src/components/clinician/documents-list.tsx b/src/components/clinician/documents-list.tsx index b5850b2dd..d9bbc1317 100644 --- a/src/components/clinician/documents-list.tsx +++ b/src/components/clinician/documents-list.tsx @@ -11,7 +11,7 @@ import { Download } from "lucide-react"; import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta"; -import { formatBytes } from "@/components/documents/vault-utils"; +import { formatBytes } from "@/lib/format/bytes"; import type { ShareViewDocument } from "@/lib/clinician-share/share-view-data"; import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents"; diff --git a/src/components/clinician/identity-sections.tsx b/src/components/clinician/identity-sections.tsx index 934982b55..9dfef4aef 100644 --- a/src/components/clinician/identity-sections.tsx +++ b/src/components/clinician/identity-sections.tsx @@ -22,6 +22,7 @@ import { type LeafScope, type Translate, } from "./report-sections"; +import { EmptyState } from "@/components/ui/empty-state"; /** * Only the three stored gender values get a label. A row on a clinical @@ -240,9 +241,11 @@ export function EmergencySection({ {t("clinicianView.sectionUnavailable")}

    ) : rows.length === 0 ? ( -

    - {t("clinicianView.sectionEmpty")} -

    + ) : ( rows.map((row) => ( ) : empty ? ( -

    - {t("clinicianView.sectionEmpty")} -

    + ) : ( children )} diff --git a/src/components/custom-metrics/custom-metric-chart.tsx b/src/components/custom-metrics/custom-metric-chart.tsx index e35a77dc8..3834a72f1 100644 --- a/src/components/custom-metrics/custom-metric-chart.tsx +++ b/src/components/custom-metrics/custom-metric-chart.tsx @@ -13,7 +13,6 @@ import { YAxis, } from "recharts"; -import { Button } from "@/components/ui/button"; import { CHART_HEIGHT_PX } from "@/lib/charts/constants"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { @@ -32,6 +31,8 @@ import { import { dateKeyOfInstant } from "../day/day-url"; import { formatMetricValue } from "./format-value"; import type { CustomMetricEntryDto } from "./types"; +import { Segmented } from "@/components/ui/segmented"; +import { ChartEmptyState } from "@/components/charts/chart-empty-state"; /** * v1.25.5 — per-custom-metric trend chart, in the dashboard's visual language. @@ -121,7 +122,11 @@ export function CustomMetricChart({ focusFor: (index) => { const point = points[index]; return point - ? { label: name, value: formatMetricValue(point.value, decimals), unit } + ? { + label: name, + value: formatMetricValue(point.value, decimals, fmt.number), + unit, + } : null; }, }); @@ -133,25 +138,21 @@ export function CustomMetricChart({ return (
    -
    - {RANGE_DAYS.map((r) => ( - - ))} +
    + ({ + value: r.key, + label: t(`customMetrics.chart.range.${r.key}`), + }))} + value={range} + onChange={setRange} + label={t("charts.rangeLabel")} + slot="custom-metric-chart-range" + />
    {points.length === 0 ? ( -

    - {t("customMetrics.chart.empty")} -

    + ) : (
    formatMetricValue(v, decimals)} + tickFormatter={(v: number) => + formatMetricValue(v, decimals, fmt.number) + } /> {targetLow != null && targetHigh != null ? ( formatMetricValue(value, decimals)} + formatValue={(value) => + formatMetricValue(value, decimals, fmt.number) + } formatDate={(date) => fmt.dateShortSmartCalendar(date)} bucket="day" metricLabel={name} diff --git a/src/components/custom-metrics/custom-metric-history-list.tsx b/src/components/custom-metrics/custom-metric-history-list.tsx index 0c327ef13..5e2b60dfa 100644 --- a/src/components/custom-metrics/custom-metric-history-list.tsx +++ b/src/components/custom-metrics/custom-metric-history-list.tsx @@ -24,7 +24,7 @@ import { Textarea } from "@/components/ui/textarea"; import { useTableSort } from "@/hooks/use-table-sort"; import { ApiError, apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch"; import { formatDateShort } from "@/lib/format"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { formatMetricValue } from "./format-value"; @@ -64,6 +64,7 @@ export function CustomMetricHistoryList({ decimals: number | null; }) { const { t } = useTranslations(); + const fmt = useFormatters(); const queryClient = useQueryClient(); const { sortBy, sortDir, toggleSort } = useTableSort({ @@ -242,7 +243,7 @@ export function CustomMetricHistoryList({ {ordered.map((r) => ( - {formatMetricValue(r.value, decimals)} + {formatMetricValue(r.value, decimals, fmt.number)} {unit ? ` ${unit}` : ""} diff --git a/src/components/custom-metrics/custom-metric-list.tsx b/src/components/custom-metrics/custom-metric-list.tsx index 232326b3d..db8f0e8ea 100644 --- a/src/components/custom-metrics/custom-metric-list.tsx +++ b/src/components/custom-metrics/custom-metric-list.tsx @@ -15,12 +15,13 @@ import { SectionHeading } from "@/components/ui/section-heading"; import { Skeleton } from "@/components/ui/skeleton"; import { apiGet } from "@/lib/api/api-fetch"; import { formatDate } from "@/lib/format"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { CustomMetricForm } from "./custom-metric-form"; import { formatMetricValue } from "./format-value"; import type { CustomMetricDto, CustomMetricListResponse } from "./types"; +import { EmptyState } from "@/components/ui/empty-state"; /** * v1.25.5 — inline list of the user's custom metrics, mounted ON the @@ -31,6 +32,7 @@ import type { CustomMetricDto, CustomMetricListResponse } from "./types"; */ export function CustomMetricList() { const { t } = useTranslations(); + const fmt = useFormatters(); // v1.36.x — the section is dropped whole inside somebody else's record. // Defining a metric is not a delegated verb, and every row here links to // `/custom-metrics/{id}`, which sharing does not cover: the shell turns a @@ -98,12 +100,10 @@ export function CustomMetricList() { onRetry={() => refetch()} /> ) : metrics.length === 0 ? ( - - - -

    - {t("customMetrics.emptyDescription")} -

    + } + title={t("customMetrics.emptyDescription")} + action={ -
    -
    + } + /> ) : ( @@ -133,6 +133,7 @@ export function CustomMetricList() { {formatMetricValue( metric.latest.value, metric.decimals, + fmt.number, )} {metric.unit ? ` ${metric.unit}` : ""} diff --git a/src/components/custom-metrics/custom-metric-values-list.tsx b/src/components/custom-metrics/custom-metric-values-list.tsx index 2607996b1..46e4bd223 100644 --- a/src/components/custom-metrics/custom-metric-values-list.tsx +++ b/src/components/custom-metrics/custom-metric-values-list.tsx @@ -17,6 +17,7 @@ import type { CustomMetricEntryDto, CustomMetricEntryListResponse, } from "./types"; +import { EmptyState } from "@/components/ui/empty-state"; const ENTRIES_PAGE_SIZE = 200; @@ -90,9 +91,11 @@ export function CustomMetricValuesList({ if (entries.length === 0) { return ( -

    - {t("customMetrics.detail.emptyTitle")} -

    + ); } diff --git a/src/components/custom-metrics/format-value.ts b/src/components/custom-metrics/format-value.ts index 6ee795c99..69e7fd68f 100644 --- a/src/components/custom-metrics/format-value.ts +++ b/src/components/custom-metrics/format-value.ts @@ -1,17 +1,20 @@ /** - * v1.25.5 — format a custom-metric value for display. + * Format a custom-metric value for display. * * When the metric defines a fixed number of decimals, honour it; otherwise fall * back to a compact representation that trims trailing zeros (up to 3 places). + * The digits come from the caller's locale formatter (`useFormatters().number`), + * so a German reader sees "70,5" where an English one sees "70.5". */ export function formatMetricValue( value: number, decimals: number | null | undefined, + formatNumber: (value: number, fractionDigits?: number) => string, ): string { if (!Number.isFinite(value)) return "—"; if (decimals != null) { - return value.toFixed(decimals); + return formatNumber(value, decimals); } // Compact default: round to at most 3 places and drop trailing zeros. - return String(Math.round(value * 1000) / 1000); + return formatNumber(Math.round(value * 1000) / 1000); } diff --git a/src/components/cycle/__tests__/cycle-phase-crosstab.test.tsx b/src/components/cycle/__tests__/cycle-phase-crosstab.test.tsx index 1e16d70e6..70e93fd0d 100644 --- a/src/components/cycle/__tests__/cycle-phase-crosstab.test.tsx +++ b/src/components/cycle/__tests__/cycle-phase-crosstab.test.tsx @@ -86,7 +86,9 @@ describe("", () => { ); expect(html).toContain("Mood"); expect(html).toContain("Blood glucose"); - expect(html).toContain("pts"); + // A mood rating has no unit word, as everywhere else a rating is shown. + expect(html).not.toContain("pts"); + expect(html).toContain("Luteal 5.0 (12 days)"); expect(html).toContain("mg/dL"); // Never leaks the raw metric key or the raw i18n key on a covered row. expect(html).not.toContain("cycle.insights.crosstab"); diff --git a/src/components/cycle/cycle-history-chart.tsx b/src/components/cycle/cycle-history-chart.tsx index 1d761e6cb..ad12c6f55 100644 --- a/src/components/cycle/cycle-history-chart.tsx +++ b/src/components/cycle/cycle-history-chart.tsx @@ -20,6 +20,7 @@ import { HISTORY_REST_OPACITY, HISTORY_PERIOD_OPACITY, } from "./phase-tokens"; +import { EmptyState } from "@/components/ui/empty-state"; /** * v1.15.1 — the cycle-length history chart. @@ -136,9 +137,11 @@ export function CycleHistoryChart({ {!hasData ? ( -

    - {t("cycle.history.none")} -

    + ) : ( <> {/* Stat chips — avg / variability / regularity classification. */} diff --git a/src/components/cycle/cycle-phase-crosstab.tsx b/src/components/cycle/cycle-phase-crosstab.tsx index 9dd962d90..4762dcdaf 100644 --- a/src/components/cycle/cycle-phase-crosstab.tsx +++ b/src/components/cycle/cycle-phase-crosstab.tsx @@ -1,13 +1,14 @@ "use client"; -import { useTranslations } from "@/lib/i18n/context"; -import { useUnitDisplay, type UnitDisplay } from "@/hooks/use-unit-display"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { Formatters } from "@/lib/format-locale"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; import { - applyDisplayTransform, - applyDisplayTransformDelta, - getReadingTransform, -} from "@/lib/measurements/display-transform"; -import { cn } from "@/lib/utils"; + crosstabReading, + MetricCrosstab, + withUnit, + type CrosstabMeasure, +} from "@/components/insights/metric-crosstab"; /** * v1.15 — CYCLE_PHASE × vital crosstab card. @@ -56,84 +57,33 @@ const METRIC_LABEL_KEY: Record = { }; /** - * Bundle labels for the displays no preference changes. Weight, the - * temperatures and glucose take their symbol from the reader's transform - * (`DISPLAY_TO_TYPE` below) and have no bundle label to fall back on. + * The measurement each row reads (`src/lib/cycle/phase-crosstab.ts`), which + * is what its unit and its preference conversion come from. Mood has none. */ -const UNIT_KEY: Partial> = { - hours: "cycle.insights.crosstab.unitHours", - steps: "cycle.insights.crosstab.unitSteps", - bpm: "cycle.insights.crosstab.unitBpm", - ms: "cycle.insights.crosstab.unitMs", - mood: "cycle.insights.crosstab.unitMood", +const METRIC_TYPE: Record = { + restingHeartRate: "RESTING_HEART_RATE", + heartRateVariability: "HEART_RATE_VARIABILITY", + sleepDuration: "SLEEP_DURATION", + steps: "ACTIVITY_STEPS", + weight: "WEIGHT", + basalBodyTemp: "BODY_TEMPERATURE", + wristTemperature: "WRIST_TEMPERATURE", + skinTemperature: "SKIN_TEMPERATURE", + bloodGlucose: "BLOOD_GLUCOSE", + mood: null, }; -const CONFIDENCE_KEY: Record = { - low: "cycle.insights.crosstab.confidenceLow", - medium: "cycle.insights.crosstab.confidenceMedium", - high: "cycle.insights.crosstab.confidenceHigh", -}; - -const CONFIDENCE_CLASS: Record = { - low: "bg-secondary text-muted-foreground", - medium: "bg-info/15 text-info", - high: "bg-success/15 text-success", -}; - -/** Whole numbers for steps; one decimal otherwise. */ -function fmt(value: number, display: CyclePhaseCrosstabDisplay): string { - return display === "steps" ? Math.round(value).toString() : value.toFixed(1); +function measureOf(row: CyclePhaseCrosstabRow): CrosstabMeasure { + return { type: METRIC_TYPE[row.metricKey] ?? null, display: row.display }; } -/** - * v1.32.26 — the two displays whose values follow the metric/imperial - * preference, mapped to the measurement type that carries the transform. Weight - * is offset-free (kg→lb); temperature is affine (°C→°F), so an ABSOLUTE phase - * average takes `toDisplay` while a phase DELTA takes `toDisplayDelta` (the - * offset must never leak into a difference). - */ -const DISPLAY_TO_TYPE: Partial> = { - kg: "WEIGHT", - celsius: "BODY_TEMPERATURE", - glucose: "BLOOD_GLUCOSE", -}; - -/** - * Unit symbol and conversions for one row, from the reader's preferences. - * Glucose follows the glucose unit (it used to print the mg/dL label from - * the bundle beside raw mg/dL values for every reader); weight and - * temperature follow metric/imperial; every other display keeps its bundle - * label and its raw value. - */ -function rowUnits( +/** Whole numbers for steps; one decimal otherwise. */ +function fmt( + value: number, display: CyclePhaseCrosstabDisplay, - unitDisplay: UnitDisplay, - t: (key: string) => string, -): { - unit: string; - abs: (v: number) => number; - delta: (v: number) => number; -} { - const type = DISPLAY_TO_TYPE[display]; - if (!type) { - // Defensive: an unmapped display must never pass undefined to `t` - // (whose resolver does `key.split(".")`). Empty unit on a miss. - const unitKey = UNIT_KEY[display]; - return { - unit: unitKey ? t(unitKey) : "", - abs: (v) => v, - delta: (v) => v, - }; - } - const transform = getReadingTransform(type, { - system: unitDisplay.preference, - glucoseUnit: unitDisplay.glucoseUnit, - }); - return { - unit: transform.displayUnit, - abs: (v) => applyDisplayTransform(v, transform), - delta: (v) => applyDisplayTransformDelta(v, transform), - }; + nf: Formatters, +): string { + return display === "steps" ? nf.integer(value) : nf.number(value, 1); } const HEADLINE_KEY: Record = { @@ -152,6 +102,7 @@ export function CyclePhaseHeadline({ headline: CyclePhaseCrosstabRow | null; }) { const { t } = useTranslations(); + const nf = useFormatters(); const unitDisplay = useUnitDisplay(); if (!headline) { return ( @@ -166,7 +117,11 @@ export function CyclePhaseHeadline({ const metricLabel = t( METRIC_LABEL_KEY[headline.metricKey] ?? headline.metricKey, ); - const { unit, delta: deltaConv } = rowUnits(headline.display, unitDisplay, t); + const { unit, delta: deltaConv } = crosstabReading( + measureOf(headline), + unitDisplay, + t, + ); const up = headline.delta >= 0; const dir = t( up @@ -176,8 +131,10 @@ export function CyclePhaseHeadline({ const params = { metric: metricLabel, // A phase delta → factor-only conversion (no affine offset). - delta: fmt(Math.abs(deltaConv(headline.delta)), headline.display), - unit, + delta: withUnit( + fmt(Math.abs(deltaConv(headline.delta)), headline.display, nf), + unit, + ), dir, }; const key = @@ -199,75 +156,48 @@ export function CyclePhaseCrosstab({ rows: CyclePhaseCrosstabRow[]; }) { const { t } = useTranslations(); + const nf = useFormatters(); const unitDisplay = useUnitDisplay(); - if (rows.length === 0) return null; - return ( -
    -

    - {t("cycle.insights.crosstab.description")} -

    -
      - {rows.map((row) => { - const metricLabel = t( - METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey, - ); - const { - unit, - abs: absConv, - delta: deltaConv, - } = rowUnits(row.display, unitDisplay, t); - // `delta` = lutealAvg − follicularAvg: positive = the vital runs - // higher in the luteal phase. The number stays NEUTRAL — higher is - // good for steps but bad for resting HR, and this board is - // observational, so colouring it would imply a verdict the data - // doesn't support. The sign prefix + `data-direction` carry it. - const up = row.delta >= 0; - const deltaText = `${up ? "+" : ""}${fmt(deltaConv(row.delta), row.display)} ${unit}`; - return ( -
    • -
      - - {t("cycle.insights.crosstab.pairLabel", { - metric: metricLabel, - })} - - - {deltaText} - - - {t(CONFIDENCE_KEY[row.confidence])} - -
      -

      - {t("cycle.insights.crosstab.detail", { - metric: metricLabel, - lutealAvg: fmt(absConv(row.lutealAvg), row.display), - follicularAvg: fmt(absConv(row.follicularAvg), row.display), - unit, - lutealDays: row.lutealDays, - follicularDays: row.follicularDays, - })} -

      -
    • - ); - })} -
    -
    + { + const metricLabel = t(METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey); + const measure = measureOf(row); + const { + unit, + abs: absConv, + delta: deltaConv, + } = crosstabReading(measure, unitDisplay, t); + // `delta` = lutealAvg − follicularAvg: positive = the vital runs + // higher in the luteal phase. + return { + key: row.metricKey, + metricKey: row.metricKey, + pairLabel: t("cycle.insights.crosstab.pairLabel", { + metric: metricLabel, + }), + up: row.delta >= 0, + deltaValue: fmt(deltaConv(row.delta), row.display, nf), + measure, + confidence: row.confidence, + detail: t("cycle.insights.crosstab.detail", { + metric: metricLabel, + lutealAvg: withUnit( + fmt(absConv(row.lutealAvg), row.display, nf), + unit, + ), + follicularAvg: withUnit( + fmt(absConv(row.follicularAvg), row.display, nf), + unit, + ), + lutealDays: row.lutealDays, + follicularDays: row.follicularDays, + }), + }; + })} + /> ); } diff --git a/src/components/cycle/cycle-ring-tile.tsx b/src/components/cycle/cycle-ring-tile.tsx index f68773888..1e7c79c55 100644 --- a/src/components/cycle/cycle-ring-tile.tsx +++ b/src/components/cycle/cycle-ring-tile.tsx @@ -10,6 +10,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { CycleRing } from "./cycle-ring"; import { PHASE_HUE } from "./phase-tokens"; import { useCycleRingCalendar } from "./use-cycle"; +import { TileHeader } from "@/components/insights/tile-header"; /** * v1.15.3 — the compact cycle ring as a WELLNESS-STRIP element. @@ -107,12 +108,7 @@ export function CycleRingTile({ className }: { className?: string }) { className, )} > -
    -
    +
    {rows.length === 0 ? ( -

    - {t("cycle.insights.symptomPatternsEmpty")} -

    + ) : (
      {rows.map((row) => { diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index 276aa37c1..2243644bb 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -6,7 +6,7 @@ import { useSearchParams } from "next/navigation"; import { Loader2, Plus, RefreshCw, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { PageHeader } from "@/components/ui/page-header"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { QueryErrorRow } from "@/components/ui/query-error-row"; @@ -73,15 +73,15 @@ const BbtChart = dynamic( { ssr: false, loading: () => ( -
      -
      + + -
      -
      - + + + -
      -
      + + ), }, ); diff --git a/src/components/cycle/predictions-panel.tsx b/src/components/cycle/predictions-panel.tsx index b0052dbac..b13ae226e 100644 --- a/src/components/cycle/predictions-panel.tsx +++ b/src/components/cycle/predictions-panel.tsx @@ -15,6 +15,7 @@ import { useTranslations, useFormatters } from "@/lib/i18n/context"; import type { Formatters } from "@/lib/format-locale"; import { FERTILE_HUE, FLOW_HUE, OVULATION_HUE } from "./phase-tokens"; import type { CyclePrediction, CycleHistoryResponse } from "./types"; +import { EmptyState } from "@/components/ui/empty-state"; /** * v1.15.0 — the predictions panel. @@ -81,9 +82,11 @@ export function PredictionsPanel({ body={t("cycle.predictions.rawModeBody")} /> ) : prediction == null ? ( -

      - {t("cycle.predictions.none")} -

      + ) : prediction.stillLearning ? ( <> {!stats || stats.avgLengthDays == null ? ( -

      - {t("cycle.history.none")} -

      + ) : (

      {t("dashboard.keyBackupBanner.body")}

    - - {t("dashboard.keyBackupBanner.action")} - + + + {t("dashboard.keyBackupBanner.action")} + +
    ); } diff --git a/src/components/day/day-view.tsx b/src/components/day/day-view.tsx index b65ab18de..320486873 100644 --- a/src/components/day/day-view.tsx +++ b/src/components/day/day-view.tsx @@ -44,6 +44,7 @@ import { tileKeyOf } from "./day-values-model"; import { shiftDateKey } from "./day-url"; import { useDay, usePrefetchDay } from "./use-day"; import { useDayValueFormat } from "./use-day-value-format"; +import { EmptyState } from "@/components/ui/empty-state"; /** * One day, as the layer shows it in every shell: a header with the date and @@ -282,9 +283,12 @@ export function DayView({ ) : data === undefined ? ( ) : isEmpty ? ( -

    - {t("day.empty")} -

    + ) : ( <> diff --git a/src/components/day/since-last-visit.tsx b/src/components/day/since-last-visit.tsx index 6a0aff03f..08f0cf5b6 100644 --- a/src/components/day/since-last-visit.tsx +++ b/src/components/day/since-last-visit.tsx @@ -200,7 +200,7 @@ export function SinceLastVisit({ // and the reader still sees what could not be loaded. if (window.isError && rows.length === 0) { return ( - + + ): string => { if (row.type === "SLEEP_DURATION") return ""; - const unit = isCanonicalUnit(row) ? units.unitFor(row.type) : row.unit; // A device score reads in points, as on its chart. - return unit === "score" ? t("insights.deviceScore.unitScore") : unit; + if (row.unit === "score") return t("insights.deviceScore.unitScore"); + return isCanonicalUnit(row) + ? units.unitFor(row.type) + : measurementUnitLabel(row.type, row.unit, t); }, [t, units], ); diff --git a/src/components/documents/__tests__/vault-utils.test.ts b/src/components/documents/__tests__/vault-utils.test.ts index f368012c5..d881f7a5e 100644 --- a/src/components/documents/__tests__/vault-utils.test.ts +++ b/src/components/documents/__tests__/vault-utils.test.ts @@ -11,7 +11,6 @@ import { documentCardKeyAction, documentDateKey, expandRangeSelection, - formatBytes, formatMonthLabel, hasProcessingDocument, isDocumentProcessing, @@ -216,15 +215,6 @@ describe("timeline items", () => { }); }); -describe("byte formatting", () => { - it("scales units and keeps one decimal below 10", () => { - expect(formatBytes(0, "en")).toBe("0 B"); - expect(formatBytes(512, "en")).toBe("512 B"); - expect(formatBytes(26_214_400, "en")).toBe("25 MB"); - expect(formatBytes(1_288_490_189, "en")).toBe("1.2 GB"); - }); -}); - describe("upload response contract (§3.2)", () => { it("HTTP 200 + meta.duplicate returns the EXISTING row as a duplicate", () => { const body = JSON.stringify({ diff --git a/src/components/documents/document-ai-panels.tsx b/src/components/documents/document-ai-panels.tsx index daf342284..b2c5e8d66 100644 --- a/src/components/documents/document-ai-panels.tsx +++ b/src/components/documents/document-ai-panels.tsx @@ -30,6 +30,7 @@ import { } from "@/lib/validations/inbound-documents"; import type { DocumentDescribeResult } from "./use-document-assist"; +import { EmptyState } from "@/components/ui/empty-state"; /** * The line under a document AI action while its read runs in the background, @@ -220,9 +221,11 @@ export function AssistSuggestionReview({ ) : null}
    ) : ( -

    - {t("documents.assist.empty")} -

    + )}
    ); diff --git a/src/components/documents/document-ai-section.tsx b/src/components/documents/document-ai-section.tsx index 36beed24d..61c819b94 100644 --- a/src/components/documents/document-ai-section.tsx +++ b/src/components/documents/document-ai-section.tsx @@ -36,13 +36,13 @@ import { Button } from "@/components/ui/button"; import type { AiRunPhase } from "@/hooks/use-ai-run"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; -import { - isAiReadSource, - type DocumentContentIndexSourceValue, - type DocumentIndexOutcomeValue, - type DocumentSuggestionDto, - type DocumentSummaryMode, +import type { + DocumentContentIndexSourceValue, + DocumentIndexOutcomeValue, + DocumentSuggestionDto, + DocumentSummaryMode, } from "@/lib/validations/inbound-documents"; +import { isAiReadSource } from "@/lib/documents/inbound-constants"; import { AiRunPhaseNote, diff --git a/src/components/documents/document-card.tsx b/src/components/documents/document-card.tsx index d40019b92..1fa4b2f2b 100644 --- a/src/components/documents/document-card.tsx +++ b/src/components/documents/document-card.tsx @@ -31,11 +31,8 @@ import { cn } from "@/lib/utils"; import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; import { DOCUMENT_KIND_ICONS } from "./document-kind-meta"; import type { UploadQueueItem } from "./use-document-upload"; -import { - documentCardKeyAction, - formatBytes, - isDocumentProcessing, -} from "./vault-utils"; +import { formatBytes } from "@/lib/format/bytes"; +import { documentCardKeyAction, isDocumentProcessing } from "./vault-utils"; /** How long the "Ready" chip stays up after a card's index lands. */ const READY_FLASH_MS = 3000; @@ -183,7 +180,12 @@ export function DocumentCard({ const titleBlock = (
    -

    {title}

    +

    + {title} +

    {/* v1.42 — a dated document's date opens its day, above the whole-card button. It follows the grid's roving tab stop, so the @@ -220,8 +222,9 @@ export function DocumentCard({ data-slot="document-card" data-variant={variant} data-document-id={document.id} + density="compact" className={cn( - "group relative h-full gap-2 py-3 transition-shadow md:py-4", + "group relative h-full transition-shadow", "hover:shadow-sm", highlighted && "ring-primary ring-2", )} diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index c5594528f..824a85b9e 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -75,14 +75,14 @@ import { useFencedObjectUrl, } from "@/hooks/use-fenced-object-url"; import { cn } from "@/lib/utils"; -import { - INBOUND_DOCUMENT_KINDS, - type DocumentSourceSystemValue, - type DocumentSuggestionDto, - type DocumentSummaryMode, - type InboundDocumentDetailDto, - type InboundDocumentKindValue, +import type { + DocumentSourceSystemValue, + DocumentSuggestionDto, + DocumentSummaryMode, + InboundDocumentDetailDto, + InboundDocumentKindValue, } from "@/lib/validations/inbound-documents"; +import { INBOUND_DOCUMENT_KINDS } from "@/lib/documents/inbound-constants"; import { DocumentAiSection } from "./document-ai-section"; import { DocumentEncounterSuggestion } from "./document-encounter-suggestion"; import { DocumentRecordLinks } from "./document-record-links"; @@ -102,7 +102,7 @@ import { useDocumentSummary, useSuggestDetails, } from "./use-document-assist"; -import { formatBytes } from "./vault-utils"; +import { formatBytes } from "@/lib/format/bytes"; import { QueryErrorRow } from "@/components/ui/query-error-row"; type PatchInput = { diff --git a/src/components/documents/document-link-picker.tsx b/src/components/documents/document-link-picker.tsx index 8e5357132..1b773d4bb 100644 --- a/src/components/documents/document-link-picker.tsx +++ b/src/components/documents/document-link-picker.tsx @@ -2,40 +2,20 @@ /** * "Verknüpfen" — link existing vault documents to an illness episode from - * the episode's detail page. A `ResponsiveSheet` with a debounced search - * over the vault (title/filename, same recall path as the vault page) and - * one toggle row per document; toggling fires the bulk link/unlink action - * for that single id (idempotent by contract, no-op-success on - * already-in-state rows) and refreshes every document read through the - * `["documents"]` prefix. - * - * Shows one page (50) of matches — the search input is the way to reach an - * older document, mirroring the vault's own recall model. + * the episode's detail page. The shared `DocumentPickerSheet` lists and + * searches the vault; toggling a row fires the bulk link/unlink action for + * that single id (idempotent by contract, no-op-success on already-in-state + * rows) and refreshes every document read through the `["documents"]` + * prefix. */ -import { useDocumentDate } from "@/components/documents/use-document-date"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Check, Search } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { showErrorToast } from "@/lib/errors/error-toast"; -import { Input } from "@/components/ui/input"; -import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; -import { Skeleton } from "@/components/ui/skeleton"; -import { QueryErrorRow } from "@/components/ui/query-error-row"; -import { apiGet, apiPost } from "@/lib/api/api-fetch"; +import { apiPost } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, queryKeys } from "@/lib/query-keys"; -import { cn } from "@/lib/utils"; -import type { - DocumentBulkResultDto, - InboundDocumentDto, -} from "@/lib/validations/inbound-documents"; -import { DOCUMENT_KIND_ICONS } from "./document-kind-meta"; - -interface ListPage { - documents: InboundDocumentDto[]; - nextCursor: string | null; -} +import type { DocumentBulkResultDto } from "@/lib/validations/inbound-documents"; +import { DocumentPickerSheet } from "./document-picker-sheet"; export function DocumentLinkPicker({ episodeId, @@ -47,39 +27,8 @@ export function DocumentLinkPicker({ onOpenChange: (open: boolean) => void; }) { const { t } = useTranslations(); - const documentDate = useDocumentDate(); const queryClient = useQueryClient(); - const [searchDraft, setSearchDraft] = useState(""); - const [query, setQuery] = useState(""); - - // 200 ms debounce, matching the vault's search feel. - const debounceRef = useRef | null>(null); - useEffect( - () => () => { - if (debounceRef.current) clearTimeout(debounceRef.current); - }, - [], - ); - const onSearchChange = (value: string) => { - setSearchDraft(value); - if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => setQuery(value.trim()), 200); - }; - - const list = useQuery({ - queryKey: queryKeys.inboundDocumentPicker(query), - enabled: open, - queryFn: () => { - const sp = new URLSearchParams(); - if (query) sp.set("q", query); - sp.set("sort", "documentDate"); - sp.set("order", "desc"); - sp.set("limit", "50"); - return apiGet(`/api/documents/inbound?${sp.toString()}`); - }, - }); - const toggle = useMutation({ mutationFn: (input: { documentId: string; linked: boolean }) => apiPost<{ results: DocumentBulkResultDto[] }>( @@ -102,97 +51,21 @@ export function DocumentLinkPicker({ }, }); - const documents = list.data?.documents ?? []; - return ( - -

    -
    - - onSearchChange(e.target.value)} - placeholder={t("documents.linkPicker.searchPlaceholder")} - aria-label={t("documents.linkPicker.searchPlaceholder")} - className="pl-9" - /> -
    - - {list.isPending && open ? ( -
    - {Array.from({ length: 4 }, (_, i) => ( - - ))} -
    - ) : list.isError ? ( - list.refetch()} - /> - ) : documents.length === 0 ? ( -

    - {t("documents.linkPicker.empty")} -

    - ) : ( -
      - {documents.map((doc) => { - const linked = doc.conditionLinks.some( - (l) => l.episodeId === episodeId, - ); - const title = - doc.title ?? doc.filename ?? t("documents.card.untitled"); - const Icon = DOCUMENT_KIND_ICONS[doc.kind]; - return ( -
    • - -
    • - ); - })} -
    - )} -
    - + searchPlaceholder={t("documents.linkPicker.searchPlaceholder")} + emptyLabel={t("documents.linkPicker.empty")} + isPicked={(doc) => + doc.conditionLinks.some((l) => l.episodeId === episodeId) + } + isDisabled={() => toggle.isPending} + onToggle={(doc, _title, linked) => + toggle.mutate({ documentId: doc.id, linked }) + } + /> ); } diff --git a/src/components/documents/document-picker-sheet.tsx b/src/components/documents/document-picker-sheet.tsx new file mode 100644 index 000000000..6794e3650 --- /dev/null +++ b/src/components/documents/document-picker-sheet.tsx @@ -0,0 +1,194 @@ +"use client"; + +/** + * The vault document picker sheet: a `ResponsiveSheet` (bottom sheet on + * phones) with a debounced search over the person's own vault — the same + * recall path as the vault page, one page (50) of matches, the search input + * as the way to an older document — and one toggle row per document. + * + * Two pickers used to carry this whole body as copies: linking documents to + * an illness episode (each toggle writes at once) and choosing documents for + * a share link (the choice is local state, capped, posted with the link). + * What a toggle means stays with each caller; the list, the search, the rows + * and their states live here once. + */ +import { useQuery } from "@tanstack/react-query"; +import { Check, Search } from "lucide-react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; + +import { useDocumentDate } from "@/components/documents/use-document-date"; +import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta"; +import { EmptyState } from "@/components/ui/empty-state"; +import { Input } from "@/components/ui/input"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { Skeleton } from "@/components/ui/skeleton"; +import { apiGet } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { cn } from "@/lib/utils"; +import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; + +interface ListPage { + documents: InboundDocumentDto[]; + nextCursor: string | null; +} + +export function DocumentPickerSheet({ + open, + onOpenChange, + title, + description, + searchPlaceholder, + emptyLabel, + isPicked, + onToggle, + isDisabled, + notice, + footer, + listTestId, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description: string; + searchPlaceholder: string; + emptyLabel: string; + isPicked: (doc: InboundDocumentDto) => boolean; + /** `title` is the row's display title (title, filename or "untitled"). */ + onToggle: (doc: InboundDocumentDto, title: string, picked: boolean) => void; + isDisabled?: (doc: InboundDocumentDto, picked: boolean) => boolean; + /** A status line above the list (the share cap). */ + notice?: ReactNode; + footer?: ReactNode; + listTestId?: string; +}) { + const { t } = useTranslations(); + const documentDate = useDocumentDate(); + + const [searchDraft, setSearchDraft] = useState(""); + const [query, setQuery] = useState(""); + + // 200 ms debounce, matching the vault's search feel. + const debounceRef = useRef | null>(null); + useEffect( + () => () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }, + [], + ); + const onSearchChange = (value: string) => { + setSearchDraft(value); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => setQuery(value.trim()), 200); + }; + + const list = useQuery({ + queryKey: queryKeys.inboundDocumentPicker(query), + enabled: open, + queryFn: () => { + const sp = new URLSearchParams(); + if (query) sp.set("q", query); + sp.set("sort", "documentDate"); + sp.set("order", "desc"); + sp.set("limit", "50"); + return apiGet(`/api/documents/inbound?${sp.toString()}`); + }, + }); + + const documents = list.data?.documents ?? []; + + return ( + +
    +
    + + onSearchChange(e.target.value)} + placeholder={searchPlaceholder} + aria-label={searchPlaceholder} + className="pl-9" + /> +
    + + {notice} + + {list.isPending && open ? ( +
    + {Array.from({ length: 4 }, (_, i) => ( + + ))} +
    + ) : list.isError ? ( + list.refetch()} + /> + ) : documents.length === 0 ? ( + + ) : ( +
      + {documents.map((doc) => { + const picked = isPicked(doc); + const rowTitle = + doc.title ?? doc.filename ?? t("documents.card.untitled"); + const Icon = DOCUMENT_KIND_ICONS[doc.kind]; + const disabled = isDisabled?.(doc, picked) ?? false; + return ( +
    • + +
    • + ); + })} +
    + )} +
    +
    + ); +} diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index 43ab0612d..187ce48a0 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -61,15 +61,15 @@ import { } from "@/lib/insights/coach-launch-context"; import { invalidateKeys, queryKeys } from "@/lib/query-keys"; import { useDocumentsLayout } from "@/lib/queries/use-documents-layout"; -import { - DOCUMENT_BULK_MAX_IDS, - type DocumentBulkAction, - type DocumentBulkResultDto, - type DocumentUsageDto, - type InboundDocumentDetailDto, - type InboundDocumentDto, - type InboundDocumentKindValue, +import type { + DocumentBulkAction, + DocumentBulkResultDto, + DocumentUsageDto, + InboundDocumentDetailDto, + InboundDocumentDto, + InboundDocumentKindValue, } from "@/lib/validations/inbound-documents"; +import { DOCUMENT_BULK_MAX_IDS } from "@/lib/documents/inbound-constants"; import { DocumentBulkBar } from "./document-bulk-bar"; import { DocumentDetailSheet } from "./document-detail-sheet"; import { DocumentShareSheet } from "./document-share-sheet"; diff --git a/src/components/documents/sources/__tests__/document-sources-ui.test.tsx b/src/components/documents/sources/__tests__/document-sources-ui.test.tsx index 208163bcb..4e3a508aa 100644 --- a/src/components/documents/sources/__tests__/document-sources-ui.test.tsx +++ b/src/components/documents/sources/__tests__/document-sources-ui.test.tsx @@ -50,7 +50,6 @@ import { queryKeys } from "@/lib/query-keys"; import { ImportFromSourceButton } from "../import-from-source-button"; import { mergeIds } from "../merge-ids"; -import { nextChoiceIndex } from "../segmented-choice"; import { errorCodeOf, sourceErrorMessage } from "../source-errors"; const PAPERLESS = { @@ -255,19 +254,6 @@ describe("source error sentences", () => { }); }); -describe("nextChoiceIndex", () => { - it("moves with the arrows, wraps, and jumps with Home and End", () => { - expect(nextChoiceIndex("ArrowRight", 0, 2)).toBe(1); - expect(nextChoiceIndex("ArrowRight", 1, 2)).toBe(0); - expect(nextChoiceIndex("ArrowLeft", 0, 2)).toBe(1); - expect(nextChoiceIndex("ArrowDown", 0, 3)).toBe(1); - expect(nextChoiceIndex("ArrowUp", 0, 3)).toBe(2); - expect(nextChoiceIndex("Home", 2, 3)).toBe(0); - expect(nextChoiceIndex("End", 0, 3)).toBe(2); - expect(nextChoiceIndex("Enter", 0, 3)).toBeNull(); - }); -}); - describe("mergeIds", () => { it("adds imported ids to a form's selection once, in order", () => { expect(mergeIds(["a", "b"], ["b", "c", "c"])).toEqual(["a", "b", "c"]); diff --git a/src/components/documents/sources/document-source-picker.tsx b/src/components/documents/sources/document-source-picker.tsx index 1ae4fc11d..c492f5607 100644 --- a/src/components/documents/sources/document-source-picker.tsx +++ b/src/components/documents/sources/document-source-picker.tsx @@ -75,14 +75,15 @@ import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documen import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; import type { WrittenOutcome } from "@/lib/outcome/written-outcome"; -import { formatBytes } from "../vault-utils"; +import { formatBytes } from "@/lib/format/bytes"; import { errorCodeOf, isStoppingError, sourceErrorMessage, } from "./source-errors"; -import { SegmentedChoice } from "./segmented-choice"; +import { Segmented } from "@/components/ui/segmented"; import { systemName } from "./use-document-sources"; +import { InlineAction } from "@/components/ui/inline-action"; export interface DocumentPickerLink { kind: DocumentPickerLinkKind; @@ -408,13 +409,16 @@ export function DocumentSourcePicker({ >
    {systems.length > 1 && active ? ( - ({ + value: system, + label: systemName(system), + }))} value={active} onChange={switchSystem} label={t("documents.sourcePicker.sourceLabel")} disabled={running} - renderOption={systemName} + className="self-start" /> ) : null} @@ -485,19 +489,19 @@ export function DocumentSourcePicker({
    {hasFilters ? ( - + ) : null} {finished ? ( diff --git a/src/components/documents/sources/segmented-choice.tsx b/src/components/documents/sources/segmented-choice.tsx deleted file mode 100644 index a140cfa5e..000000000 --- a/src/components/documents/sources/segmented-choice.tsx +++ /dev/null @@ -1,110 +0,0 @@ -"use client"; - -/** - * A small segmented radio group (Paperless-ngx | Papra) with the keyboard - * contract of a radio group: one tab stop (the checked option), arrow keys - * move and select, Home and End jump to the ends. Shared by the document - * picker's source switch and the settings card's system switch. - */ -import { useRef, type KeyboardEvent, type ReactNode } from "react"; - -import { cn } from "@/lib/utils"; - -/** - * The option an arrow, Home or End key moves to from `index`, wrapping at the - * ends; null for any other key. - */ -export function nextChoiceIndex( - key: string, - index: number, - count: number, -): number | null { - if (count <= 0) return null; - switch (key) { - case "ArrowRight": - case "ArrowDown": - return (index + 1) % count; - case "ArrowLeft": - case "ArrowUp": - return (index - 1 + count) % count; - case "Home": - return 0; - case "End": - return count - 1; - default: - return null; - } -} - -export function SegmentedChoice({ - options, - value, - onChange, - label, - disabled = false, - renderOption, - slot, - className, -}: { - options: readonly T[]; - value: T; - onChange: (value: T) => void; - /** Accessible name of the group. */ - label: string; - disabled?: boolean; - renderOption: (option: T) => ReactNode; - slot?: string; - className?: string; -}) { - const refs = useRef>([]); - - const onKeyDown = (event: KeyboardEvent, i: number) => { - const next = nextChoiceIndex(event.key, i, options.length); - if (next === null) return; - event.preventDefault(); - if (disabled) return; - onChange(options[next]); - refs.current[next]?.focus(); - }; - - return ( -
    - {options.map((option, i) => { - const checked = option === value; - return ( - - ); - })} -
    - ); -} diff --git a/src/components/documents/upload-zone.tsx b/src/components/documents/upload-zone.tsx index c94863234..40386a4ba 100644 --- a/src/components/documents/upload-zone.tsx +++ b/src/components/documents/upload-zone.tsx @@ -19,7 +19,7 @@ import { type RefObject } from "react"; import { Progress } from "@/components/ui/progress"; import { useTranslations } from "@/lib/i18n/context"; import type { DocumentUsageDto } from "@/lib/validations/inbound-documents"; -import { formatBytes } from "./vault-utils"; +import { formatBytes } from "@/lib/format/bytes"; /** Usage fraction above which the quota bar appears. */ const QUOTA_BAR_THRESHOLD = 0.8; diff --git a/src/components/documents/vault-utils.ts b/src/components/documents/vault-utils.ts index 3e4c12175..b87e3cfaf 100644 --- a/src/components/documents/vault-utils.ts +++ b/src/components/documents/vault-utils.ts @@ -7,13 +7,15 @@ * and byte formatting. Kept out of the client components so they stay * trivially unit-testable without a render harness. */ +import type { + DocumentLinkedProcedureDto, + InboundDocumentDto, + InboundDocumentKindValue, +} from "@/lib/validations/inbound-documents"; import { DOCUMENT_LIST_DEFAULT_LIMIT, INBOUND_DOCUMENT_KINDS, - type DocumentLinkedProcedureDto, - type InboundDocumentDto, - type InboundDocumentKindValue, -} from "@/lib/validations/inbound-documents"; +} from "@/lib/documents/inbound-constants"; import type { DocumentVaultFilters } from "@/lib/query-keys/documents"; import { userDayKey } from "@/lib/tz/format"; @@ -302,21 +304,6 @@ export function formatMonthLabel(key: string, locale: string): string { }).format(new Date(`${key}-15T12:00:00.000Z`)); } -/** Locale byte formatting: 0 B / 412 KB / 1.2 MB / 2.4 GB. */ -export function formatBytes(bytes: number, locale: string): string { - const units = ["B", "KB", "MB", "GB"] as const; - let value = Math.max(0, bytes); - let unit = 0; - while (value >= 1024 && unit < units.length - 1) { - value /= 1024; - unit += 1; - } - const digits = unit === 0 || value >= 100 ? 0 : value >= 10 ? 1 : 1; - return `${new Intl.NumberFormat(locale, { - maximumFractionDigits: digits, - }).format(value)} ${units[unit]}`; -} - /** * Shift-click range selection over the timeline's visual order. Selects * every id between the last plainly-toggled anchor and the target diff --git a/src/components/encounters/__tests__/visits-section.test.tsx b/src/components/encounters/__tests__/visits-section.test.tsx index d8c54ca80..31f95b25f 100644 --- a/src/components/encounters/__tests__/visits-section.test.tsx +++ b/src/components/encounters/__tests__/visits-section.test.tsx @@ -308,9 +308,7 @@ describe(" since the last visit", () => { html.indexOf("since-last-visit-"), ), ); - expect(card).toMatch( - /^
    @@ -102,7 +102,6 @@ export function VisitCard({ size="sm" icon={CalendarClock} title={heading} - titleClassName="min-w-0 truncate" right={ {encounterKindText(t, encounter.kind as EncounterKind)} diff --git a/src/components/gamification/recent-achievements-card.tsx b/src/components/gamification/recent-achievements-card.tsx index 2c778df45..42582f521 100644 --- a/src/components/gamification/recent-achievements-card.tsx +++ b/src/components/gamification/recent-achievements-card.tsx @@ -27,6 +27,9 @@ import { useAchievementsQuery } from "@/lib/queries/use-achievements-query"; import type { AchievementProgress } from "@/lib/gamification/achievements"; import { Skeleton } from "@/components/ui/skeleton"; import { TileHeader } from "@/components/insights/tile-header"; +import { EmptyState } from "@/components/ui/empty-state"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { InlineAction } from "@/components/ui/inline-action"; const iconMap: Record = { Activity, @@ -126,82 +129,84 @@ export function RecentAchievementsCard() { } return ( -
    + {/* Canonical tile header (foreground h-5 icon, text-base h2) so the dashboard card matches its sibling tiles instead of the old muted h-4 / text-sm row. The "view all" link rides the header's right slot (§11 inline-action-row). */} - - {t("achievements.dashboardCard.viewAll")} - - } - /> + + + + {t("achievements.dashboardCard.viewAll")} + + + } + /> + - {showSkeleton ? ( - - ) : recent.length === 0 ? ( -

    - {t("achievements.dashboardCard.empty")} -

    - ) : ( -
      - {recent.map((achievement) => { - const Icon = iconMap[achievement.icon] ?? Star; - return ( + + {showSkeleton ? ( + - )} -
    + ))} + + ) : recent.length === 0 ? ( + + ) : ( +
      + {recent.map((achievement) => { + const Icon = iconMap[achievement.icon] ?? Star; + return ( +
    • +
      + +
      +
      +

      + {t(achievement.titleKey)} +

      +

      + {t(achievement.descriptionKey)} +

      +
      + {achievement.completedAt && ( + + {formatDate(achievement.completedAt)} + + )} +
    • + ); + })} +
    + )} +
    + ); } diff --git a/src/components/illness/illness-day-timeline.tsx b/src/components/illness/illness-day-timeline.tsx index dbf60b7b6..47a636a7f 100644 --- a/src/components/illness/illness-day-timeline.tsx +++ b/src/components/illness/illness-day-timeline.tsx @@ -26,6 +26,7 @@ import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog"; import { useIllnessDayLogList } from "./use-illness"; import type { IllnessDayLogDTO } from "./types"; import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { EmptyState } from "@/components/ui/empty-state"; function symptomLabelKey(key: string): string | null { return ILLNESS_SYMPTOM_CATALOG.find((s) => s.key === key)?.labelKey ?? null; @@ -64,9 +65,11 @@ function DayLogRow({ log }: { log: IllnessDayLogDTO }) { })}
    ) : ( -

    - {t("illness.timeline.noSymptoms")} -

    + )} {log.functionalImpact !== null ? ( @@ -134,20 +137,18 @@ export function IllnessDayTimeline({ onRetry={() => void refetch()} /> ) : dayLogs.length === 0 ? ( -
    -

    - {t("illness.timeline.empty")} -

    - {canManageIllness && ( - - )} -
    + + {t("illness.logDay")} + + ) : undefined + } + /> ) : (
    {dayLogs.map((log) => ( diff --git a/src/components/illness/illness-insights-card.tsx b/src/components/illness/illness-insights-card.tsx index b15c14335..c74e53a1e 100644 --- a/src/components/illness/illness-insights-card.tsx +++ b/src/components/illness/illness-insights-card.tsx @@ -21,6 +21,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { useIllnessInsights } from "./use-illness"; import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { EmptyState } from "@/components/ui/empty-state"; export function IllnessInsightsCard({ windowDays = 365, @@ -64,9 +65,11 @@ export function IllnessInsightsCard({ onRetry={() => void counts.refetch()} /> ) : !hasEpisodes ? ( -

    - {t("illness.insights.none")} -

    + ) : (

    diff --git a/src/components/illness/log-day-sheet.tsx b/src/components/illness/log-day-sheet.tsx index 85b3e0c86..5284689ab 100644 --- a/src/components/illness/log-day-sheet.tsx +++ b/src/components/illness/log-day-sheet.tsx @@ -24,7 +24,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { useUnitDisplay } from "@/hooks/use-unit-display"; import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog"; @@ -141,6 +141,7 @@ export function LogDaySheet({ onsetDate, }: LogDaySheetProps) { const { t } = useTranslations(); + const fmt = useFormatters(); // The fever is stored in °C and typed in the reader's temperature unit: // seeded through the transform, inverted on save. const { toDisplay, fromDisplay, unitFor } = useUnitDisplay(); @@ -185,7 +186,8 @@ export function LogDaySheet({ setFunctionalImpact(dto?.functionalImpact ?? null); setFever( dto?.feverC != null - ? String(toDisplay("BODY_TEMPERATURE", dto.feverC)) + ? // Seeded in the reader's notation; the parser below takes either mark. + fmt.number(toDisplay("BODY_TEMPERATURE", dto.feverC)) : "", ); setNote(dto?.note ?? ""); @@ -345,7 +347,7 @@ export function LogDaySheet({ summary={ feverValue != null ? ( - {feverValue.toFixed(1)} {feverUnit} + {fmt.number(feverValue, 1)} {feverUnit} ) : null } @@ -357,7 +359,7 @@ export function LogDaySheet({ setFever(e.target.value)} className="max-w-32" diff --git a/src/components/insights/__tests__/health-score-card-geometry.test.tsx b/src/components/insights/__tests__/health-score-card-geometry.test.tsx index 448e53764..500824677 100644 --- a/src/components/insights/__tests__/health-score-card-geometry.test.tsx +++ b/src/components/insights/__tests__/health-score-card-geometry.test.tsx @@ -11,6 +11,7 @@ import type { } from "@/lib/analytics/score/types"; import { SCORE_VERSION } from "@/lib/analytics/score/types"; import { HeroStrip } from "../hero-strip"; +import { primeLocales } from "@/__tests__/helpers/prime-locales"; /** * The hero band's geometry, measured rather than asserted from source. @@ -42,6 +43,11 @@ import { HeroStrip } from "../hero-strip"; * chromium` once to measure for real. */ +// Every locale's own catalog, not the English fallback: without it the +// per-locale sweep below measured the English names eight times over and +// never saw a German compound. +primeLocales(...locales); + vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ user: { unitPreference: "metric", glucoseUnit: "mg/dL" } }), })); @@ -52,6 +58,8 @@ const COLUMN_PX = 352; const VIEWPORT_PX = 1024; /** One `text-xs` row is a 16 px line; two lines would exceed this. */ const ONE_LINE_PX = 20; +/** Two `text-xs` lines; a third would mean the name no longer fits. */ +const TWO_LINES_PX = 2 * ONE_LINE_PX; /** * The row's info trigger carries `-mx-2` so its 44 px tap target does not * inflate a 20 px row: it reaches 8 px into the card's own padding on each @@ -358,7 +366,7 @@ describe("hero band geometry", () => { ).toBeLessThanOrEqual(heights.rows6); }, 60_000); - it("keeps every row on one line in every locale", async (ctx) => { + it("keeps every row inside its column and every name whole in every locale", async (ctx) => { if (skipReason) ctx.skip(skipReason); const widest: Record = {}; @@ -408,6 +416,8 @@ describe("hero band geometry", () => { labelOverflow: label ? getComputedStyle(label).textOverflow : "none", + labelScroll: label?.scrollWidth ?? 0, + labelClient: label?.clientWidth ?? 0, barWidth: Math.round(bar?.getBoundingClientRect().width ?? 0), valueScroll: value?.scrollWidth ?? 0, valueClient: value?.clientWidth ?? 0, @@ -416,10 +426,11 @@ describe("hero band geometry", () => { }; }); - // The row is the invariant, not the label: a long name may ellipsise, - // but it may never widen the row or wrap it onto a second line. Drop - // `min-w-0` or `truncate` from the label and the first grid track - // grows to the longest word instead, which is exactly this. + // The row's width is the invariant, and the name is read whole: a + // long name breaks onto a second line at a syllable, it is never cut. + // Drop `min-w-0` from the label and the first grid track grows to the + // longest word instead; drop `hyphens-auto` and a German compound + // overflows its track. expect( m.rowScroll, `${locale}/${m.id}: the row is pushed wider than its column by "${m.text}"`, @@ -428,14 +439,18 @@ describe("hero band geometry", () => { m.labelWidth, `${locale}/${m.id}: the label track grew past 7rem for "${m.text}"`, ).toBeLessThanOrEqual(112 + 1); + expect( + m.labelScroll, + `${locale}/${m.id}: "${m.text}" is cut off inside its track`, + ).toBeLessThanOrEqual(m.labelClient + 1); expect( m.labelHeight, - `${locale}/${m.id}: "${m.text}" wrapped onto a second line`, - ).toBeLessThanOrEqual(ONE_LINE_PX); + `${locale}/${m.id}: "${m.text}" needs more than two lines`, + ).toBeLessThanOrEqual(TWO_LINES_PX); expect( m.labelOverflow, - `${locale}/${m.id}: "${m.text}" is cut without an ellipsis`, - ).toBe("ellipsis"); + `${locale}/${m.id}: "${m.text}" ends in an ellipsis`, + ).not.toBe("ellipsis"); // The label column may not eat the two things the row exists to show. expect( m.barWidth, diff --git a/src/components/insights/__tests__/integer-metric-precision-guard.test.tsx b/src/components/insights/__tests__/integer-metric-precision-guard.test.tsx index 1e56b9872..a7e4ad6c6 100644 --- a/src/components/insights/__tests__/integer-metric-precision-guard.test.tsx +++ b/src/components/insights/__tests__/integer-metric-precision-guard.test.tsx @@ -173,7 +173,9 @@ describe("integer-metric precision guard", () => { const html = render(); expect(html).toContain('data-slot="metric-stat-strip"'); - expect(html).not.toMatch(FRACTIONAL_GERMAN); + // Class lists carry grid templates such as `minmax(0,1fr)`; only the + // rendered text is under test. + expect(html.replace(/class="[^"]*"/g, "")).not.toMatch(FRACTIONAL_GERMAN); // The numbers themselves survive, thousands-grouped and whole. expect(html).toContain("11.762"); expect(html).toContain("104"); diff --git a/src/components/insights/__tests__/metric-crosstab.test.tsx b/src/components/insights/__tests__/metric-crosstab.test.tsx new file mode 100644 index 000000000..12fa7baff --- /dev/null +++ b/src/components/insights/__tests__/metric-crosstab.test.tsx @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Moon } from "lucide-react"; + +vi.mock("@/hooks/use-unit-display", async () => { + const { metricUnitDisplay } = + await import("@/__tests__/helpers/metric-unit-display"); + return { useUnitDisplay: () => metricUnitDisplay() }; +}); + +import { I18nProvider } from "@/lib/i18n/context"; +import { MetricCrosstab, type MetricCrosstabRow } from "../metric-crosstab"; + +function row(overrides: Partial = {}): MetricCrosstabRow { + return { + key: "a", + metricKey: "restingHeartRate", + pairLabel: "Resting heart rate", + up: true, + deltaValue: "2,5", + measure: { type: "RESTING_HEART_RATE", display: "bpm" }, + confidence: "medium", + detail: "62 vs 59.5 bpm", + ...overrides, + }; +} + +function render(rows: MetricCrosstabRow[]) { + return renderToStaticMarkup( + + + , + ); +} + +describe("", () => { + it("renders nothing for no rows", () => { + expect(render([])).toBe(""); + }); + + it("prints the delta as given, signed, and never coloured", () => { + const html = render([row()]); + expect(html).toContain('data-slot="board"'); + expect(html).toContain('data-slot="board-row"'); + expect(html).toContain("+2,5 bpm"); + expect(html).toContain('data-direction="up"'); + expect(html).not.toMatch(/text-(success|destructive)/); + }); + + it("leaves a negative delta's own sign alone", () => { + const html = render([row({ up: false, deltaValue: "-1,2" })]); + expect(html).toContain("-1,2 bpm"); + expect(html).not.toContain("+-1,2"); + expect(html).toContain('data-direction="down"'); + }); + + it("states confidence through the grade chip", () => { + const html = render([row({ confidence: "high" })]); + expect(html).toMatch(/data-slot="score-pill"[^>]*data-tone="success"/); + expect(html).toContain("High confidence"); + }); + + it("draws the row icon when one is given", () => { + expect(render([row({ icon: Moon })])).toContain(" { + const html = render([row({ patternId: "p1", dismissed: true })]); + expect(html).not.toContain('data-slot="board-row"'); + }); + + it("takes the unit from the measure, through the shared unit table", () => { + const html = render([ + row(), + row({ + key: "b", + measure: { type: "ACTIVITY_STEPS", display: "steps" }, + deltaValue: "120", + }), + row({ + key: "c", + measure: { type: null, display: "mood" }, + deltaValue: "0,4", + }), + ]); + expect(html).toContain("+2,5 bpm"); + // The word comes from `measurementUnitLabel` (`insights.units.steps`). + expect(html).toContain("+120 steps"); + // A rating has no unit word: the number stands alone, no trailing space. + expect(html).toContain(">+0,4<"); + }); + + it("wraps a long pair label instead of cutting it", () => { + const html = render([row({ pairLabel: "A very long custom mood tag" })]); + expect(html).toMatch(/data-slot="metric-crosstab-pair"/); + expect(html).not.toContain("truncate"); + }); +}); diff --git a/src/components/insights/__tests__/metric-target-summary.test.tsx b/src/components/insights/__tests__/metric-target-summary.test.tsx index 2491772cf..61651bf8a 100644 --- a/src/components/insights/__tests__/metric-target-summary.test.tsx +++ b/src/components/insights/__tests__/metric-target-summary.test.tsx @@ -214,10 +214,21 @@ describe("", () => { // The diastolic range bar renders alongside the systolic one. const barCount = (html.match(/data-slot="target-range-bar"/g) ?? []).length; expect(barCount).toBe(2); + // Each stacked bar names its reading, the systolic one first. The + // systolic label was missing while the diastolic one rendered. + const sys = html.indexOf(">Systolic

    "); + const dia = html.indexOf(">Diastolic

    "); + expect(sys).toBeGreaterThan(-1); + expect(dia).toBeGreaterThan(sys); // 30-day average stitches the diastolic pair — kept on the BP panel. expect(html).toContain("30-day average: 122/76 mmHg"); }); + it("labels a single-band bar with nothing extra", () => { + const html = renderWith("weight", WEIGHT_DATA); + expect(html).not.toContain(">Systolic

    "); + }); + it("keeps the adjust-target affordance out of the card body", () => { // The "Adjust target range" affordance moved to the page-header gear // (`` via `TargetAdjustProvider`); the card is now diff --git a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx index 59fd59051..74207d86a 100644 --- a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx +++ b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx @@ -8,6 +8,7 @@ import { STAGE_ORDER, buildCompositionRows, compositionTotals, + stagesInWindow, windowHasNap, type SleepStageBreakdown, type SleepStageNight, @@ -182,10 +183,10 @@ describe("", () => { expect(html).toContain('data-slot="sleep-stage-window-30"'); // The 7d button is default-active. expect(html).toMatch( - /data-slot="sleep-stage-window-7"[^>]*aria-pressed="true"/, + /aria-pressed="true"[^>]*data-slot="sleep-stage-window-7"/, ); expect(html).toMatch( - /data-slot="sleep-stage-window-14"[^>]*aria-pressed="false"/, + /aria-pressed="false"[^>]*data-slot="sleep-stage-window-14"/, ); }); @@ -343,3 +344,49 @@ describe("nap band", () => { ); }); }); + +describe("legend follows the visible stages", () => { + const label = (dayKey: string) => dayKey; + + it("leaves a stage the window never holds out of the mounted bands", () => { + // A staged week: deep, REM, core and wake bouts, no unspecified "asleep" + // minutes. Every mounted band is a legend entry, so ASLEEP must not be one. + const perNight: SleepStageNight[] = [ + { + dayKey: "2026-06-03", + stages: { DEEP: 60, REM: 90, CORE: 250, AWAKE: 15 }, + }, + { dayKey: "2026-06-04", stages: { DEEP: 55, REM: 80, CORE: 260 } }, + ]; + const { rows } = buildCompositionRows(perNight, 7, label); + expect(stagesInWindow(rows)).toEqual(["DEEP", "REM", "CORE", "AWAKE"]); + }); + + it("keeps the unspecified stage when an older source sends it", () => { + const perNight: SleepStageNight[] = [ + { dayKey: "2026-06-03", stages: { ASLEEP: 410, AWAKE: 20 } }, + ]; + const { rows } = buildCompositionRows(perNight, 7, label); + expect(stagesInWindow(rows)).toEqual(["ASLEEP", "AWAKE"]); + }); + + it("keeps the stack order whatever order the payload uses", () => { + const perNight: SleepStageNight[] = [ + { dayKey: "2026-06-03", stages: { AWAKE: 10, CORE: 200, DEEP: 40 } }, + ]; + const { rows } = buildCompositionRows(perNight, 7, label); + expect(stagesInWindow(rows)).toEqual(["DEEP", "CORE", "AWAKE"]); + }); + + it("follows the window, not the whole series", () => { + const perNight: SleepStageNight[] = [ + { dayKey: "2026-05-01", stages: { ASLEEP: 400 } }, + ...Array.from({ length: 7 }, (_, i) => ({ + dayKey: `2026-06-0${i + 1}`, + stages: { CORE: 250, DEEP: 50 }, + })), + ]; + const { rows } = buildCompositionRows(perNight, 7, label); + expect(stagesInWindow(rows)).toEqual(["DEEP", "CORE"]); + }); +}); diff --git a/src/components/insights/__tests__/tile-header-wrap.test.tsx b/src/components/insights/__tests__/tile-header-wrap.test.tsx new file mode 100644 index 000000000..111fced1a --- /dev/null +++ b/src/components/insights/__tests__/tile-header-wrap.test.tsx @@ -0,0 +1,175 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Heart } from "lucide-react"; + +import { + stripComments, + walkSourceFiles, +} from "@/__tests__/helpers/source-files"; + +import { TileHeader } from "../tile-header"; + +/** + * A tile title names the figure under it, so it wraps and never truncates. + * Seven callers passed `titleClassName="truncate"` and German compounds + * ("Erholungs-Score", "Herzfrequenzvariabilität") ended in an ellipsis on a + * narrow tile. The class override is gone; the primitive owns the wrap. + */ +function titleClasses(html: string): string { + const match = html.match( + /data-slot="card-title"[^>]*class="([^"]*)"|class="([^"]*)"[^>]*data-slot="card-title"/, + ); + expect(match, "card title not rendered").not.toBeNull(); + return match![1] ?? match![2]; +} + +describe(" title", () => { + it.each([ + ["default", undefined], + ["h2 heading", "h2" as const], + ])("wraps at a syllable as a %s", (_name, titleAs) => { + const html = renderToStaticMarkup( + , + ); + const classes = titleClasses(html); + expect(classes).toContain("min-w-0"); + expect(classes).toContain("break-words"); + expect(classes).toContain("hyphens-auto"); + expect(classes).not.toContain("truncate"); + expect(classes).not.toContain("whitespace-nowrap"); + }); + + it("keeps the compact variant on the same contract", () => { + const html = renderToStaticMarkup( + , + ); + const classes = titleClasses(html); + expect(classes).toContain("text-sm"); + expect(classes).toContain("hyphens-auto"); + expect(classes).not.toContain("truncate"); + }); +}); + +describe("no card title is cut", () => { + // A `CardTitle` or a tile `TileHeader` that truncates hides the one word + // that says what the card is. `truncate` stays legal on rows and meta + // lines that carry their full text in `title`; never on a card title. + const SRC = join(process.cwd(), "src"); + const TITLE_TAG = /<(CardTitle|TileHeader)\b[^>]*?>/g; + const CUT = /\b(truncate|whitespace-nowrap|titleClassName)\b/; + + const offenders: string[] = []; + let tags = 0; + for (const root of ["components", "app"]) { + for (const rel of walkSourceFiles(join(SRC, root), { + floor: root === "app" ? 100 : 500, + extensions: [".tsx"], + })) { + if (rel.includes("__tests__/")) continue; + const source = stripComments(readFileSync(join(SRC, root, rel), "utf8")); + for (const match of source.matchAll(TITLE_TAG)) { + tags += 1; + if (CUT.test(match[0])) offenders.push(`${root}/${rel}: ${match[1]}`); + } + } + } + + it("reads the title tags in the tree", () => { + expect(tags).toBeGreaterThan(60); + }); + + it("finds none that cut", () => { + expect(offenders).toEqual([]); + }); + + it("would catch one", () => { + const planted = `x`; + const found = [...planted.matchAll(TITLE_TAG)].filter((m) => + CUT.test(m[0]), + ); + expect(found).toHaveLength(1); + }); +}); + +/** + * A tile in a grid row reserves two title lines, so a wrapping title does not + * push its value and sparkline below the neighbours'. Proven on the header + * row's fixed height, and held for the grid tiles by a source check. + */ +describe("", () => { + function headerRow(html: string): string { + const match = html.match(/]*data-slot="tile-header"[^>]*>/); + expect(match, "tile header not rendered").not.toBeNull(); + return match![0]; + } + + it("gives a one-line and a two-line title the same row height", () => { + const short = headerRow( + renderToStaticMarkup( + , + ), + ); + const long = headerRow( + renderToStaticMarkup( + , + ), + ); + expect(short).toBe(long); + expect(short).toContain("h-10"); + expect(short).toContain("items-start"); + const sm = headerRow( + renderToStaticMarkup( + , + ), + ); + expect(sm).toContain("h-9"); + }); + + it("clamps a third line and keeps the full title readable", () => { + const html = renderToStaticMarkup( + , + ); + expect(titleClasses(html)).toContain("line-clamp-2"); + expect(html).toContain('title="Herzfrequenzvariabilität"'); + }); + + it("counter-check: without the reserve the row has no fixed height", () => { + const row = headerRow( + renderToStaticMarkup(), + ); + expect(row).not.toMatch(/\bh-(9|10)\b/); + expect(row).toContain("items-center"); + }); + + const GRID_TILES = [ + "components/insights/derived/sparkline-delta-tile.tsx", + "components/insights/derived/vitals-dashboard.tsx", + "components/insights/device-score-tile.tsx", + "components/insights/recovery/resilience-tile.tsx", + ]; + + it.each(GRID_TILES)("%s reserves the title lines", (rel) => { + const source = stripComments( + readFileSync(join(process.cwd(), "src", rel), "utf8"), + ); + const headers = (source.match(/ + void list.refetch()} /> ) : documents.length === 0 ? ( -

    - {t("insights.coach.attach.pickerEmpty")} -

    + ) : (
      {documents.map((doc) => { diff --git a/src/components/insights/coach-panel/chat-bubble.tsx b/src/components/insights/coach-panel/chat-bubble.tsx index 0c0cec5b3..81c6345a3 100644 --- a/src/components/insights/coach-panel/chat-bubble.tsx +++ b/src/components/insights/coach-panel/chat-bubble.tsx @@ -39,6 +39,7 @@ import type { CoachProvenanceMetric, } from "@/lib/ai/coach/types"; import { translateOrNull } from "@/lib/i18n/translate-code"; +import { InlineAction } from "@/components/ui/inline-action"; /** * Per-message bubble renderer for the Coach thread, split out of @@ -775,13 +776,11 @@ function ChatBubbleImpl({ setup instead of inviting an endless retry. */} {(errorCode === "coach.provider.none" || errorCode === "ai.provider.none") && ( - - {t("insights.coach.errorNoProviderAction")} - + + + {t("insights.coach.errorNoProviderAction")} + + )} {/* v1.18.1 — one-tap cadence-suggestion action card. Live from the streaming hook, or restored from the diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index 733e78050..429c23c9b 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -19,7 +19,7 @@ import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; import type { CoachFollowUp, CoachScope } from "@/lib/ai/coach/types"; import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context"; import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents"; -import { MAX_COACH_ATTACHMENTS } from "@/lib/validations/inbound-documents"; +import { MAX_COACH_ATTACHMENTS } from "@/lib/documents/inbound-constants"; import { useModuleEnabled } from "@/hooks/use-module-enabled"; import { parseUploadResponse, diff --git a/src/components/insights/coach-panel/coach-reasoning-field.tsx b/src/components/insights/coach-panel/coach-reasoning-field.tsx index 8c97d8600..4218d1c25 100644 --- a/src/components/insights/coach-panel/coach-reasoning-field.tsx +++ b/src/components/insights/coach-panel/coach-reasoning-field.tsx @@ -38,7 +38,7 @@ import { type ReasoningLevel, } from "@/lib/ai/reasoning/levels"; import type { CoachReasoningState } from "@/lib/ai/reasoning/resolve"; -import { coachReasoningLevel } from "@/lib/validations/coach-prefs"; +import { coachReasoningLevel } from "@/lib/ai/coach/prefs-constants"; export type { CoachReasoningState }; diff --git a/src/components/insights/coach-panel/coach-results.tsx b/src/components/insights/coach-panel/coach-results.tsx index 49a537b78..17e924823 100644 --- a/src/components/insights/coach-panel/coach-results.tsx +++ b/src/components/insights/coach-panel/coach-results.tsx @@ -35,7 +35,7 @@ import { ChartLine, Table2 } from "lucide-react"; import { ChartErrorBoundary } from "@/components/charts/chart-error-state"; import { ChartSkeleton } from "@/components/charts/chart-skeleton"; import { QueryErrorRow } from "@/components/ui/query-error-row"; -import { ViewToggle } from "@/components/ui/view-toggle"; +import { Segmented } from "@/components/ui/segmented"; import { useCoachMessageResults } from "@/hooks/use-coach-message-results"; import { COACH_RESULT_UI_KEYS, @@ -286,12 +286,14 @@ function CoachResultView({ } const toggle = ( - - view={view} + + mode="toggle" + iconOnly + value={view} onChange={setView} - groupLabel={t(COACH_RESULT_UI_KEYS.viewLabel)} - dataSlotPrefix="coach-result-view" - segments={[ + label={t(COACH_RESULT_UI_KEYS.viewLabel)} + optionSlot={(value) => `coach-result-view-${value}`} + options={[ { value: "chart", label: t(COACH_RESULT_UI_KEYS.viewChart), diff --git a/src/components/insights/coach-panel/conversation-rename.tsx b/src/components/insights/coach-panel/conversation-rename.tsx index a55664613..ec2c771ff 100644 --- a/src/components/insights/coach-panel/conversation-rename.tsx +++ b/src/components/insights/coach-panel/conversation-rename.tsx @@ -6,7 +6,7 @@ import { Check, Loader2, Pencil, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { COACH_CONVERSATION_TITLE_MAX } from "@/lib/ai/coach/types"; +import { COACH_CONVERSATION_TITLE_MAX } from "@/lib/ai/coach/scope-sources"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; diff --git a/src/components/insights/coach-panel/history-rail.tsx b/src/components/insights/coach-panel/history-rail.tsx index a1b8f3178..6afb178d9 100644 --- a/src/components/insights/coach-panel/history-rail.tsx +++ b/src/components/insights/coach-panel/history-rail.tsx @@ -30,6 +30,7 @@ import { useDeleteCoachConversationWithUndo, } from "./use-coach"; import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { EmptyState } from "@/components/ui/empty-state"; /** * The Coach's conversation list: the full history grouped by recency @@ -169,12 +170,13 @@ export function HistoryRail({ className="px-2 py-3" /> ) : groups.length === 0 ? ( -

      - {t("insights.coach.historyEmpty")} -

      + /> ) : ( groups.map((group) => (
      = - coachDataClusterEnum.options; +const CLUSTER_OPTIONS: ReadonlyArray = COACH_DATA_CLUSTERS; /** * The lookback options, narrowest first. The choice is a limit diff --git a/src/components/insights/confidence-badge.ts b/src/components/insights/confidence-badge.ts deleted file mode 100644 index 6a9b275c4..000000000 --- a/src/components/insights/confidence-badge.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * v1.4.20 — shared confidence-band classnames. - * - * Both `` and `` paint a discrete - * "low / moderate / high" badge with byte-identical semantic utility - * classes. Per-component i18n key maps stay local because the copy - * differs (correlationRow.* vs trendAnnotation.*); only the classname - * table is the load-bearing duplication. - */ -export type ConfidenceBand = "low" | "moderate" | "high"; - -export const CONFIDENCE_BADGE_CLASS: Record = { - high: "border-success/40 bg-success/10 text-success", - moderate: "border-warning/40 bg-warning/10 text-warning", - // Neutral (non-status) tier. The former `*-dracula-comment/*` classes - // had no generated utility (`--color-dracula-comment` was never in the - // @theme block) and silently painted nothing; border/muted is the real - // neutral vocabulary. - low: "border-border bg-muted/50 text-muted-foreground", -}; diff --git a/src/components/insights/correlation-card.tsx b/src/components/insights/correlation-card.tsx index da3ba4eb6..1e96315e6 100644 --- a/src/components/insights/correlation-card.tsx +++ b/src/components/insights/correlation-card.tsx @@ -2,7 +2,6 @@ import dynamic from "next/dynamic"; import { Activity, Sparkles } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { TileHeader } from "@/components/insights/tile-header"; @@ -12,7 +11,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import type { CorrelationResult } from "@/lib/insights/correlations"; import type { CoachScopeSource } from "@/lib/ai/coach/types"; -import { CONFIDENCE_BADGE_CLASS } from "./confidence-badge"; +import { CONFIDENCE_TONE, ScorePill } from "@/components/ui/score-pill"; import { AskCoachAction } from "./ask-coach-action"; import { PatternDismissButton, @@ -134,16 +133,12 @@ export function CorrelationCard({ result }: CorrelationCardProps) { title={title} right={ result.status === "ok" ? ( - {t(CONFIDENCE_LABEL_KEY[result.confidenceBand.label])} - + ) : undefined } /> diff --git a/src/components/insights/cross-link-card.tsx b/src/components/insights/cross-link-card.tsx new file mode 100644 index 000000000..b738783af --- /dev/null +++ b/src/components/insights/cross-link-card.tsx @@ -0,0 +1,68 @@ +import type { CSSProperties } from "react"; +import Link from "next/link"; +import { ArrowRight, type LucideIcon } from "lucide-react"; + +import { Card } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; +import { cn } from "@/lib/utils"; + +/** + * A whole card that points to another page: the tile header, one meta line, + * and the call to action. The pulse page's way to VO₂ max and the ECG pointer + * on the heart pages were two hand-rolled copies of it, one of them without + * the `md:p-6` step every other card on the page has. + * + * The link is the outer element and carries the focus ring; the surface is a + * `Card`, so padding, radius and shadow are the card contract. The compact + * card rhythm (`gap-1.5`) keeps the three lines together. + */ +export function CrossLinkCard({ + href, + slot, + icon, + title, + body, + cta, + className, + cardClassName, + style, +}: { + href: string; + slot: string; + icon: LucideIcon; + title: string; + body: string; + cta: string; + className?: string; + /** Classes on the card surface (an identity edge such as `metric-accent`). */ + cardClassName?: string; + style?: CSSProperties; +}) { + return ( + + + + + {body} + + + {cta} + + + + ); +} diff --git a/src/components/insights/derived/__tests__/score-trend-chart.test.tsx b/src/components/insights/derived/__tests__/score-trend-chart.test.tsx index 0cda9f76c..3e655d79b 100644 --- a/src/components/insights/derived/__tests__/score-trend-chart.test.tsx +++ b/src/components/insights/derived/__tests__/score-trend-chart.test.tsx @@ -94,7 +94,7 @@ describe("", () => { const html = render(30, { points: POINTS, band: null }); const tabs = html.match(/]*data-slot="chart-range-tab"[^>]*>/g); expect(tabs).toHaveLength(4); - expect(tabs!.map((tab) => tab.match(/data-range="(\d+)"/)![1])).toEqual([ + expect(tabs!.map((tab) => tab.match(/data-value="(\d+)"/)![1])).toEqual([ "7", "30", "90", @@ -102,7 +102,7 @@ describe("", () => { ]); const pressed = tabs!.filter((tab) => tab.includes('aria-pressed="true"')); expect(pressed).toHaveLength(1); - expect(pressed[0]).toContain('data-range="30"'); + expect(pressed[0]).toContain('data-value="30"'); expect(html).toContain('data-slot="score-trend-chart"'); // The range is remembered under the page's own slot. expect(prefs.lastKey).toBe("scoreHealth"); @@ -115,7 +115,7 @@ describe("", () => { html.match(/]*data-slot="chart-range-tab"[^>]*>/g) ?? [] ).filter((tab) => tab.includes('aria-pressed="true"')); expect(pressed).toHaveLength(1); - expect(pressed[0]).toContain('data-range="90"'); + expect(pressed[0]).toContain('data-value="90"'); // The seeded 90-day read is the one the chart drew from. expect(html.match(/data-slot="chart-data-table-row"/g)).toHaveLength(3); }); diff --git a/src/components/insights/derived/coach-read-strip.tsx b/src/components/insights/derived/coach-read-strip.tsx index f5983b58a..9af8420dc 100644 --- a/src/components/insights/derived/coach-read-strip.tsx +++ b/src/components/insights/derived/coach-read-strip.tsx @@ -118,7 +118,7 @@ export function CoachReadStrip({ data-slot="coach-read-strip" data-state={isError ? "error" : "loading"} aria-busy={isError ? undefined : "true"} - className="gap-2 py-3 md:py-4" + density="compact" > + + {/* The canonical tile-header primitive (was a hand-rolled copy of it): leading foreground icon + foreground `CardTitle` heading, provenance in the `right` slot. Routing through the primitive keeps this dense @@ -168,8 +163,8 @@ export function SparklineDeltaTile({ icon={Icon} title={label} titleAs="h2" + reserveLines={2} className="min-w-0" - titleClassName="min-w-0 truncate whitespace-nowrap" right={ provenance ? ( ) : null} -
    + ); } diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx index e86fe7efd..6444988df 100644 --- a/src/components/insights/derived/vitals-dashboard.tsx +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -1,12 +1,19 @@ "use client"; -import { useMemo, type ReactNode } from "react"; -import { Footprints, Gauge, HeartPulse, Scale } from "lucide-react"; +import { useMemo, type ComponentProps, type ReactNode } from "react"; +import { + Footprints, + Gauge, + HeartPulse, + Scale, + type LucideIcon, +} from "lucide-react"; import { cn } from "@/lib/utils"; import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Skeleton } from "@/components/ui/skeleton"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; -import { getUnitForType } from "@/lib/validations/measurement"; +import { measurementUnitLabel } from "@/lib/measurements/unit-label"; +import { getUnitForType } from "@/lib/measurements/unit-map"; import { useUnitDisplay } from "@/hooks/use-unit-display"; import { MEASUREMENT_TYPE_ICONS, @@ -37,6 +44,7 @@ import type { BmiValue } from "@/lib/insights/derived/bmi"; import type { SixMinuteWalkValue } from "@/lib/insights/derived/six-minute-walk"; import type { SameTimeBaselineValue } from "@/lib/insights/derived/same-time-baseline"; import type { DerivedProvenance } from "@/lib/insights/derived/types"; +import { TrendTile } from "@/components/ui/trend-tile"; /** * v1.10.0 — the Vitals dashboard surface (Apple-Health-Highlights grid). @@ -129,6 +137,11 @@ const CUMULATIVE_TILE_LAYOUT_ID: Record = { * `null` layout (caller passed none) is treated as everything-visible so the * default behaviour is unchanged. */ +/** The reader's unit for a tile whose value has no unit preference. */ +function unitLabel(type: string, t: (key: string) => string): string { + return measurementUnitLabel(type, getUnitForType(type), t); +} + function tileVisible( layout: InsightsLayout | undefined, tileId: string, @@ -240,29 +253,19 @@ function BaselineTile({ // Provisional — building the band; show value-less coverage state. if (data.status === "insufficient") { return ( -
    - -

    - {t("insights.derived.vitals.building", { - count: data.coverage.historyDays, - target: 7, - })} -

    - -
    + ); } @@ -349,29 +352,19 @@ function SameTimeBaselineTile({ // occupying the grid with an apology. if (data.reason !== "learning_usual_day") return null; return ( -
    - -

    - {t("insights.derived.sameTime.learning", { - count: data.coverage.historyDays, - target: SAME_TIME_BASELINE_MIN_HISTORY_DAYS, - })} -

    - -
    + ); } @@ -444,7 +437,7 @@ function SixMinuteWalkTile({ read, isLoading }: TileProps) { - -

    - {t("insights.derived.vitals.building", { - count: data.coverage.historyDays, - target: 7, - })} -

    - -
    + ); } const v = data.value!; @@ -585,7 +571,7 @@ function HrvBalanceTile({ read, isLoading }: TileProps) { ["coverage"]; +}) { + return ( + + +

    + {message} +

    + +
    + ); +} + /** * A loading placeholder occupying the resolved tile footprint so the grid * reserves its final height and the real tiles drop in without a layout @@ -633,18 +659,20 @@ function BmiTile({ read, isLoading }: TileProps) { */ function VitalsTileSkeleton() { return ( -