diff --git a/.env.production.example b/.env.production.example index 0428201a7..ecf31e293 100644 --- a/.env.production.example +++ b/.env.production.example @@ -533,9 +533,11 @@ API_TOKEN_HMAC_KEY="" # TRUST_CF_CONNECTING_IP="1" # Memory cap of the app container (read by docker-compose.yml, not by the app). -# Node sizes its heap from it: 1536m (the default) gives 792 MB, 1g only 524 MB, -# which a long-used account fills in normal operation. Lower it on a host with -# 2 GB or less, raise it on a busy instance. +# Node sizes its heap from it: 1536m (the default) gives 792 MB. 1536m is the +# supported floor: below it a long-used account fills the heap in normal +# operation, and the boot log, the support report and Admin > System status +# say the app runs below the supported floor. The host needs 3 GB of RAM, +# 4 GB recommended. Raise it on a busy instance; do not lower it. # APP_MEMORY_LIMIT="1536m" # ----------------------------------------------------------------------------- diff --git a/CHANGELOG.md b/CHANGELOG.md index ff9c8570e..7e0f73c02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,113 @@ # Changelog +## [1.44.0] — 2026-10-22 + +### Added + +- **Import lab results and medications from a spreadsheet.** Settings > + Export & Import has a card that reads the head of a CSV file, suggests a + column for each field from its header, and lets you correct the + separator, decimal mark, date order and time zone. A check run lists + every refused row with its line and reason before anything is written, + and importing the same file again only finds duplicates. Templates for + both kinds download from the card. (#1215) +- **An activity calendar** on the workouts page: a week or month of steps, + active minutes, workouts by sport and training load, each day measured + against your own usual range and each period against the ones before. + Weekly and monthly goals are optional. Today counts as in progress until + it is over. +- **Fitness, fatigue and form** above the workout list, from the training + impulse HealthLog already stores. A day with activity data but no + training counts as rest; a day without any data is left out instead of + counted, and the card says how current the figures are. +- **Where a value comes from.** Each metric page links to a view that lists + every source with readings for a day, its place in your source priority + and which one the charts use, with a side-by-side comparison of two + sources over 30, 90 or 365 days. +- **Lab trends.** A marker with at least three readings over a year shows + its slope, and when the straight line would leave the reference range + within three years. A series whose newest reading is more than a year + old is shown without a projection. +- **The change at a medication start**, as a level and a trend change with + intervals, next to the existing before and after comparison. Days with + missed doses, pauses and other medications that changed in the window are + accounted for, and the view says when the data are too thin. +- **More in the doctor report:** sleep duration, HRV as SDNN and RMSSD, + daily steps, a workout summary per sport and the medication effect, each + only when its module is on. The medication effect is included only when + the medication list is shared too. The report names the window it + analysed. +- **More correlations:** water, caffeine, micronutrients, alcohol days, + workout minutes, training impulse, activity ring minutes, State of Mind + and room climate against that night's sleep. State of Mind, mindful + minutes and alcohol days stay on the correlations page and are never + handed to the Coach or the narrative cards. +- **Level shifts in thirteen vitals over 180 days** instead of five over 90. + Slow drifts and plateaus no longer count as a shift. +- **Mental health screeners** show whether a change reaches the threshold + that counts as meaningful. +- **Apple Health:** exercise, stand and mindful minutes, stand hours, State + of Mind and AFib burden. Workout GPS tracks are kept only when you turn + that on (Settings > Data & privacy); tracks already stored stay, and + accounts that already kept tracks start with it on. +- **Oura:** workouts, intraday heart rate and a full history backfill that + resumes after a restart. +- **Indoor climate** (CO2, temperature, humidity) through the measurement + API, with a Home Assistant blueprint. +- **A guardian can connect a managed profile's integrations** (Withings, + WHOOP, Oura, Polar, Strava, Google Health), and each card says whose + record it connects for. +- **FHIR:** Observations as a time series by `date` and `code`, with RMSSD + and workouts coded. + +### Changed + +- **Settings are sorted into nine groups:** Account, Tracking, + Arrangement, Notifications, Health profile, AI, Connections, Data and + System. Every old address still works and forwards to the new place. On + a phone one button names the current section and opens the list. Each + card saves the same way and asks once before anything destructive, and + German wording uses one word per thing. +- **All OAuth integrations refresh their tokens the same way**, so a + refresh that races another one no longer signs you out of Strava, Google + Health or Withings. One provider account can be linked to only one + record. +- **Imports from Apple Health insert large batches faster**, and the health + check answers "starting" while the background jobs come up, so a slow + boot no longer marks the container unhealthy. +- **The metric pages load about 90 KB less JavaScript** on first visit. + +### Fixed + +- Oura no longer imports the equivalent walking distance as distance. +- A workout synced from two sources counts once in strain, the briefing + and the day view. +- The Coach's provider and model switch is back, and notification channel + switches act on the first tap again. +- Jobs queued during start-up wait for the queue instead of being dropped. + +### Upgrade notes + +- **1536 MB is the supported memory floor for the app container.** This is + the existing default of `APP_MEMORY_LIMIT`; if you lowered it, raise it + back. Below the floor the app still starts, but the boot log, the + support report and Admin > System status say so. Plan for 3 GB of RAM on + the host. +- Migrations 0424, 0430 to 0434, 0440 to 0442 and 0470 run on start. They + add columns and settings, turn the GPS track setting on for accounts + that already have tracks, and drop a cached copy of the AI insights + text, which is rebuilt on the next run. Workouts with a track but no + elevation get their climb filled in by a background pass after the + first start. +- If you use Fitbit, connect Google Health before 30 October 2026, when + Google turns the old Fitbit API off. + +### Thanks + +Thanks to @Cnote43 for the case for a spreadsheet import of twenty years of +lab results (#1215), and to @alondin and @haritowa for staying with the +Oura connection until it held (#1204). + ## [1.43.11] — 2026-10-21 ### Fixed diff --git a/Dockerfile b/Dockerfile index 6f58628aa..338364c89 100644 --- a/Dockerfile +++ b/Dockerfile @@ -320,8 +320,11 @@ ENV HOSTNAME="0.0.0.0" # 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 +# Five retries: a pool saturated for a minute during a boot storm makes the +# probe's 2 s database check miss once or twice without marking the container +# unhealthy (docker-compose.yml explains the numbers). +HEALTHCHECK --interval=30s --timeout=5s --start-period=10m --retries=5 \ + CMD wget --no-verbose --tries=1 --timeout=4 --spider http://127.0.0.1:3000/api/health || exit 1 ENTRYPOINT ["./docker-entrypoint.sh"] CMD ["node", "server.js"] diff --git a/bundle-budget.json b/bundle-budget.json index bc3ed7437..2fdc452d6 100644 --- a/bundle-budget.json +++ b/bundle-budget.json @@ -1,5 +1,5 @@ { - "$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.", + "$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. Raised to 3900 at v1.44.0 on a measured delta, with two first-load reductions in the same release. Main measured 3737 KB gz without the lazy catalogs and the release branch 3863, the same figure the CI runner reported. Bundling every client module of each build once, deduplicated, measured 1494 and 1509 KB gz: the release adds about 15 KB of new client code (the CSV import card, the activity calendar and its goals sheet, the source provenance view, the efficacy summary, the training load and lab trend cards, the settings sheet on phones) and no dependency. The rest is the double count described at v1.37.27 and v1.40.0, attributed module by module from source maps: the shared destructive-control confirmation now used by fifteen more settings sections sits in 26 chunks instead of 11, and the two new routes (/settings/modules/[module] and /insights/sources) each carry their own copy of the record-settings gate's on-demand chunks, the date field and its popover, the settings shell and the type label map. Two reductions land beside the raise. The value domain read the plausibility band out of the measurement validation module, which put Zod into the first load of every metric sub-page; the band lives in a Zod-free module now and about fifty metric routes fell by 88.5 KB gz each (/insights/pulse 428 to 339). The arrangement pages imported their module link card from the file that holds the labs, mood and injection-site settings and shipped all three; the card has its own module (/settings/layout/[module] 391 to 380). Across the 108 pages the summed first load fell from 39998 to 35895 KB gz; 48 pages fell by more than 1 KB, 13 grew by more than 1 KB and none by more than 9 (/insights/workouts with the training load card, /enroll-mfa with the sessions and trusted devices that moved into the security section). The aggregate moved the other way, 3863 to 3875: the band is a small module now copied into each chunk group that reads it, where it used to ride in the one shared schema chunk. The five watched routes stay inside their allowances (/ 359, /measurements 339, /insights/mood 337, /insights 323, /settings/[section] 311 KB gz). The aggregate cap moves from 3755 to 3900, about 25 KB above the build, the same headroom as before; per-route baselines are unchanged.", "routeBaselineKbGz": { "/page": 355, "/insights/page": 323, @@ -8,7 +8,7 @@ "/settings/[section]/page": 307 }, "routeDriftAllowanceKbGz": 20, - "totalClientKbGz": 3755, + "totalClientKbGz": 3900, "maxRechartsChunks": 1, "maxCatalogKbGz": 210 } diff --git a/docker-compose.yml b/docker-compose.yml index fb778bee4..dad7cf80d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -503,25 +503,38 @@ services: # workload can grow large enough to starve Postgres on a shared host. # Node sizes its heap from this limit: 1g gives a 524 MB heap, which a # long-used account fills in normal operation, so garbage collection - # stalls requests for seconds. 1536m gives 792 MB. The support report and - # Admin > System status flag a heap below 700 MB. On a host with 2 GB or less, lower it with - # APP_MEMORY_LIMIT in .env; on a busy instance, raise it. + # stalls requests for seconds. 1536m gives 792 MB and is the supported + # floor (host: 3 GB RAM, 4 GB recommended). Below it the boot log, the + # support report and Admin > System status say so. On a busy instance, + # raise it with APP_MEMORY_LIMIT in .env; do not lower it. mem_limit: "${APP_MEMORY_LIMIT:-1536m}" healthcheck: test: [ "CMD-SHELL", - "wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health || exit 1", + "wget --no-verbose --tries=1 --timeout=4 --spider http://127.0.0.1:3000/api/health || exit 1", ] interval: 30s timeout: 5s - retries: 3 + # Five misses in a row, two and a half minutes, before "unhealthy". The + # probe gives the database two seconds to answer SELECT 1 and says 503 + # past that, so a boot storm that saturates the pool for a minute after + # the start period (backfills, a restore, another tenant's import) costs + # a probe or two, not the container's health and the proxy's routing. + # wget's own 4 s timeout ends the request inside Docker's 5 s, so a + # stalled connection reads as one clean miss rather than a killed probe. + # A database that stays away for 2.5 minutes is a real outage and shows. + retries: 5 # 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. + # listening. After it listens, the pg-boss worker starts in the + # background and /api/health answers 503 with reason "starting" until it + # is up (10–60 s under load, reported as "starting" for at most five + # minutes); inside the start period those probes do not count, so a slow + # worker start cannot mark the container unhealthy. start_period: 10m # Optional dedicated worker container — uncomment + set diff --git a/docs/README.md b/docs/README.md index 34465adf4..aabff21cc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -56,7 +56,11 @@ notes, and machine-readable specs that ship alongside the source: — the iOS submission gate. - [`codex-protocol-spec.md`](./codex-protocol-spec.md) — Codex / ChatGPT OAuth protocol reverse-engineering notes. -- [`ui-guidelines.md`](./ui-guidelines.md) — UI / a11y house style. +- [`contributing/ui-standards.md`](./contributing/ui-standards.md) — the + binding UI standards: card anatomy, spacing, text colour, tokens and + which primitive to use where. Read before touching any surface. +- [`ui-guidelines.md`](./ui-guidelines.md) — UI / a11y house style; where + it disagrees with the UI standards, the standards win. If you're a self-hoster looking to spin up an instance, start at or jump to the [Quick Start in the project diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9b4f13c5b..6cc0471ec 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.11 + version: 1.44.0 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -1245,8 +1245,9 @@ paths: summary: FHIR R4 CapabilityStatement (v1.11.0) description: "Read-only FHIR R4 capability statement for the REST face. Declares the served resource types (Patient, Coverage, Observation, MedicationStatement, MedicationAdministration), their `read` and `search-type` - interactions, the `Patient/$everything` operation, and the `application/fhir+json` format. Every advertised - interaction has a route. Auth: `fhir:read` scope (cookie sessions also pass)." + interactions, the search parameters (`_count`, `_offset`; `date` and `code` on Observation), the + `Patient/$everything` operation, and the `application/fhir+json` format. Every advertised interaction has a + route. Auth: `fhir:read` scope (cookie sessions also pass)." responses: "200": description: CapabilityStatement (application/fhir+json). @@ -1273,7 +1274,9 @@ paths: - in: query name: _offset schema: + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. type: number + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). @@ -1291,7 +1294,16 @@ paths: - FHIR summary: FHIR R4 Observation search (v1.11.0) description: "Read-only `searchset` Bundle of the caller's own Observations (vitals / activity / lab / survey). Auth: - `fhir:read` scope. Offset paging via `_count` (clamped ≤200) / `_offset`." + `fhir:read` scope. Offset paging via `_count` (clamped ≤200) / `_offset`. Without `date` the answer is the + snapshot it has always been: one latest reading per type plus the derived panels, ids `obs-N`. With `date` + (v1.44, repeatable, FHIR prefixes eq/gt/ge/lt/le, a value without an offset read as UTC) the answer is the time + series instead: every reading in the range plus workouts, newest first, paged in the database, ids `m-` / + `w-` that read back through `/api/fhir/Observation/{id}`. The series follows the same saved report selection + as the snapshot, leaves sleep out (stored as stage segments), pairs a systolic and diastolic recorded at the + same instant into the blood-pressure panel, and codes a workout as an `activity` Observation (LOINC 73985-4 + Exercise activity, duration 55411-3 and energy 41981-2 as components). `code` (repeatable, `system|code` or a + bare code, comma-separated) narrows either answer; an unknown code matches nothing. A malformed `date` is a 400 + OperationOutcome." parameters: - in: query name: _count @@ -1300,7 +1312,17 @@ paths: - in: query name: _offset schema: + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. type: number + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. + - in: query + name: date + schema: + type: string + - in: query + name: code + schema: + type: string responses: "200": description: searchset Bundle (application/fhir+json). @@ -1327,7 +1349,9 @@ paths: - in: query name: _offset schema: + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. type: number + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). @@ -1354,7 +1378,9 @@ paths: - in: query name: _offset schema: + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. type: number + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). @@ -1382,7 +1408,9 @@ paths: - in: query name: _offset schema: + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. type: number + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). @@ -1463,9 +1491,10 @@ paths: tags: - FHIR summary: FHIR R4 Observation read - description: "Read-only `Observation` by id, out of the caller's own record. An id belonging to someone else is - indistinguishable from one that never existed — both answer a `not-found` OperationOutcome. Auth: `fhir:read` - scope; `userId` is narrowed from auth." + description: "Read-only `Observation` by id, out of the caller's own record: a snapshot id (`obs-N`) or a series id + (`m-` / `w-`, v1.44), the latter read inside the same saved selection the series search applies. An id + belonging to someone else is indistinguishable from one that never existed — both answer a `not-found` + OperationOutcome. Auth: `fhir:read` scope; `userId` is narrowed from auth." parameters: - in: path name: id @@ -1576,7 +1605,9 @@ paths: - in: query name: _offset schema: + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. type: number + description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). @@ -4315,6 +4346,15 @@ paths: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY - in: query name: from schema: @@ -5014,6 +5054,394 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 + /api/activity/calendar: + get: + tags: + - Measurements + summary: Activity calendar against the person's own normal + description: "A week (Monday to Sunday) or a month of activity, computed server-side without AI: per local day the step + total (the canonical-source sum the steps chart draws), active minutes (the larger of exercise minutes and + workout minutes), workouts by sport after the cross-source collapse, and a training-load score when one exists. + Each day is placed against the person's own spread over the 84 days before the period; the period total against + the median of the twelve weeks or six months before it, pro-rated while the period runs. Never a population + norm. Weekly goals are reported per calendar row, the monthly step goal for a month. Owned by the Workouts + module." + parameters: + - in: query + name: period + schema: + default: month + description: "Calendar span: one week (Monday to Sunday) or one month." + type: string + enum: + - week + - month + description: "Calendar span: one week (Monday to Sunday) or one month." + - in: query + name: date + schema: + description: Any day inside the period to show (`YYYY-MM-DD`, the person's local day). Defaults to today. A day after + today, or more than ten years back, is refused. + type: string + format: date + 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])))$ + description: Any day inside the period to show (`YYYY-MM-DD`, the person's local day). Defaults to today. A day after + today, or more than ten years back, is refused. + responses: + "200": + description: The calendar. + content: + application/json: + schema: + $ref: "#/components/schemas/ActivityCalendarEnvelope" + "401": *a1 + "403": *a13 + "422": *a4 + "429": *a5 + /api/activity/goals: + get: + tags: + - Measurements + summary: Activity goals + description: "The person's own activity goals: workouts, active minutes and steps per week, steps per month. `null` = + not set; there is no default. Owner only; a delegate reads the goals inside the calendar." + responses: + "200": + description: The goals. + content: + application/json: + schema: + $ref: "#/components/schemas/ActivityGoalsEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 + patch: + tags: + - Measurements + summary: Set activity goals + description: "Sets the goals the body names and keeps the rest; `null` clears a goal. Owner only: a shared or delegated + session cannot change another person's goals." + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ActivityGoalsPatch" + responses: + "200": + description: The goals after the change. + content: + application/json: + schema: + $ref: "#/components/schemas/ActivityGoalsPatchEnvelope" + "401": *a1 + "422": *a4 + "429": *a12 + /api/measurements/provenance: + get: + tags: + - Measurements + summary: Which source a metric's day value comes from + description: "For one metric and one UTC day: every source with readings, its day value (the total for a cumulative + metric, sleep counting asleep stages only, the mean otherwise), its rank on the person's source-priority ladder, + and which source the canonical readers use, with the reason. Computed by the same rank SQL the charts, rollups + and summaries pick with, so the answer is what they show. A metric without a ladder reports `ranked: false` and + no pick: every source counts there. Read-only." + parameters: + - in: query + name: type + schema: + type: string + enum: + - 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 + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY + description: The metric. + required: true + description: The metric. + - in: query + name: date + schema: + type: string + format: date + 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])))$ + description: The UTC day (`YYYY-MM-DD`). The source ladder picks one source per UTC day, so this is the day the pick is + explained for. + required: true + description: The UTC day (`YYYY-MM-DD`). The source ladder picks one source per UTC day, so this is the day the pick is + explained for. + responses: + "200": + description: The day's sources and the pick. + content: + application/json: + schema: + $ref: "#/components/schemas/DayProvenanceEnvelope" + "401": *a1 + "403": *a11 + "422": *a4 + "429": *a5 + /api/measurements/provenance/compare: + get: + tags: + - Measurements + summary: Two sources of one metric side by side + description: "Per UTC day over up to 366 days: the day value from source `a` and from source `b`, and which source the + ladder picked that day among all of the day's sources. The summary averages only the days both sources cover. + Without `a` / `b` the two sources covering the most days are compared. Read-only." + parameters: + - in: query + name: type + schema: + type: string + enum: + - 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 + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY + description: The metric. + required: true + description: The metric. + - in: query + name: from + schema: + type: string + format: date + 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])))$ + description: First UTC day, inclusive. + required: true + description: First UTC day, inclusive. + - in: query + name: to + schema: + type: string + format: date + 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])))$ + description: Last UTC day, inclusive. + required: true + description: Last UTC day, inclusive. + - in: query + name: a + schema: + description: First source. Defaults to the source covering the most days. + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + description: First source. Defaults to the source covering the most days. + - in: query + name: b + schema: + description: Second source. Defaults to the next one. + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + description: Second source. Defaults to the next one. + responses: + "200": + description: The comparison. + content: + application/json: + schema: + $ref: "#/components/schemas/SourceComparisonEnvelope" + "401": *a1 + "403": *a11 + "422": *a4 + "429": *a5 /api/devices: post: tags: @@ -9455,6 +9883,52 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 + /api/auth/me/workout-routes: + get: + tags: + - Auth + summary: Read the workout-route opt-in + description: Whether the GPS track of a workout is kept. Off by default. While it is off, `POST /api/workouts/batch` and + the Apple Health export import keep every workout but drop its `route`; the per-entry status is the same as with + the route stored. No AI path ever reads a stored track. + responses: + "200": + description: The current flag. + content: + application/json: + schema: + $ref: "#/components/schemas/GetWorkoutRoutesEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 + patch: + tags: + - Auth + summary: Set the workout-route opt-in + description: Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user; body capped at 1 KB. Turning it off + stops new tracks; tracks already stored stay until their workout is deleted. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/WorkoutRoutesPatchRequest" + responses: + "200": + description: The resolved next flag. + content: + application/json: + schema: + $ref: "#/components/schemas/PatchWorkoutRoutesEnvelope" + "401": *a1 + "413": + description: Request body exceeds 1024 bytes. Nothing was written. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 /api/auth/me/use-central-codex: get: tags: @@ -14488,6 +14962,81 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 + /api/settings/module-list-layout: + get: + tags: + - Meta + summary: Get the list presentation of labs, illness and checkups + description: Every list resolved, defaults filled in, with `saved` per list. Never writes a row. + responses: + "200": + description: The resolved presentation of every list. + content: + application/json: + schema: + $ref: "#/components/schemas/ModuleListLayoutEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 + put: + tags: + - Meta + summary: Change the presentation of one list + description: "Preserve-when-absent: the fields the body leaves out keep their stored value, and every other list stays + as stored. Returns every list resolved." + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ModuleListLayoutPutBody" + responses: + "200": + description: The presentation as stored. + content: + application/json: + schema: + $ref: "#/components/schemas/ModuleListLayoutSavedEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 + /api/settings/preview-features: + get: + tags: + - Meta + summary: Get the preview-feature opt-ins + description: Every flag, off unless the caller switched it on. + responses: + "200": + description: The opt-ins. + content: + application/json: + schema: + $ref: "#/components/schemas/PreviewFeaturesEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 + patch: + tags: + - Meta + summary: Switch preview features on or off + description: Sets the flags the body names and keeps the rest. An unknown flag or an empty body is refused with 422. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PreviewFeaturesPatchBody" + responses: + "200": + description: The opt-ins as stored. + content: + application/json: + schema: + $ref: "#/components/schemas/PreviewFeaturesSavedEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 /api/settings/privacy-summary: get: tags: @@ -14771,17 +15320,21 @@ paths: reason `duplicate` and not written. Without it a row is deduplicated on `(type, measuredAt)` and a repeat is `skipped` / `duplicate`. Writes commit in chunks rather than as one transaction; a failure part-way leaves earlier chunks written, and re-uploading the same file completes the rest. `?dryRun=1` validates + previews - without writing. Shares the 5/hour `import:` rate bucket." + without writing. Shares the 5/hour `import:` rate bucket. An unknown query parameter is refused with 422, so a + misspelt `dryRun` never writes." parameters: - - name: dryRun - in: query - required: false + - in: query + name: dryRun schema: + description: "`1` validates every row and reports what would happen, writing nothing." + default: "0" type: string enum: + - "0" - "1" - "true" - description: When `1` / `true`, parse + validate + return the per-row envelope without writing. + - "false" + description: "`1` validates every row and reports what would happen, writing nothing." requestBody: required: true content: @@ -14809,6 +15362,402 @@ paths: $ref: "#/components/schemas/CsvImportTooLargeEnvelope" "422": *a4 "429": *a5 + /api/import/labs-csv: + post: + tags: + - Import + summary: Import lab results from CSV + description: "Body is the raw CSV file (`text/csv`, ≤ 16 MB), read as a stream: a UTF-8 byte-order mark is dropped, the + separator is `,`, `;` or a tab, quoted fields may hold separators and line breaks. Every row is checked on its + own and a refused row is reported by line with a reason, never guessed (a number or date that reads two ways is + refused). Re-uploading the same file writes nothing: every row is reported as a duplicate. `dryRun=1` checks the + whole file without writing and returns the first rows as they would be stored; it spends a separate bucket of 30 + checks an hour, the import itself the shared 5-an-hour `import:` bucket. Owner only; refused with + `module.disabled` when the module is switched off. An unknown query parameter is refused with 422, so a misspelt + `dryRun` never writes. Each row's name resolves to the account's marker the way a scanned report's rows do + (name, alias, catalog name); a new analyte mints one marker from its first row. A numeric result needs a unit; + one in another unit is converted when the conversion is known and keeps the printed value beside it, otherwise + the row is refused with `unit_mismatch`. Any other text in the value column is a qualitative result. A date + without a time is stored as that calendar date. At most 20 000 data rows." + parameters: + - in: query + name: dryRun + schema: + description: "`1` validates every row and reports what would happen, writing nothing." + default: "0" + type: string + enum: + - "0" + - "1" + - "true" + - "false" + description: "`1` validates every row and reports what would happen, writing nothing." + - in: query + name: header + schema: + description: "`1` when the first row holds column names and is not data." + default: "1" + type: string + enum: + - "0" + - "1" + description: "`1` when the first row holds column names and is not data." + - in: query + name: delimiter + schema: + default: auto + description: Column separator; `auto` detects it from the first line. + type: string + enum: + - auto + - comma + - semicolon + - tab + description: Column separator; `auto` detects it from the first line. + - in: query + name: decimal + schema: + default: dot + description: Decimal mark of the numbers. A number that reads differently in the two conventions (`1,234` with `dot`) is + refused per row as ambiguous. + type: string + enum: + - dot + - comma + description: Decimal mark of the numbers. A number that reads differently in the two conventions (`1,234` with `dot`) is + refused per row as ambiguous. + - in: query + name: dateOrder + schema: + default: iso + description: "`iso` = YYYY-MM-DD[THH:mm[:ss][offset]], `dmy` = DD.MM.YYYY (also / or -), `mdy` = MM/DD/YYYY. Two-digit + years are refused." + type: string + enum: + - iso + - dmy + - mdy + description: "`iso` = YYYY-MM-DD[THH:mm[:ss][offset]], `dmy` = DD.MM.YYYY (also / or -), `mdy` = MM/DD/YYYY. Two-digit + years are refused." + - in: query + name: timezone + schema: + description: IANA zone a date-time without an offset is read in. Defaults to the account's zone. A date without a time + is stored as that calendar date. + type: string + maxLength: 64 + description: IANA zone a date-time without an offset is read in. Defaults to the account's zone. A date without a time + is stored as that calendar date. + - in: query + name: analyte + schema: + type: integer + minimum: 0 + maximum: 99 + description: Column of the marker name (required). + required: true + description: Column of the marker name (required). + - in: query + name: value + schema: + type: integer + minimum: 0 + maximum: 99 + description: Column of the result (required). A number is a numeric result; any other text (`negative`, `<0.5`) is kept + as a qualitative result. + required: true + description: Column of the result (required). A number is a numeric result; any other text (`negative`, `<0.5`) is kept + as a qualitative result. + - in: query + name: unit + schema: + description: Column of the unit. Required for a numeric result. + type: integer + minimum: 0 + maximum: 99 + description: Column of the unit. Required for a numeric result. + - in: query + name: date + schema: + type: integer + minimum: 0 + maximum: 99 + description: Column of the date, or date and time (required). + required: true + description: Column of the date, or date and time (required). + - in: query + name: time + schema: + description: Column of the time, when separate. + type: integer + minimum: 0 + maximum: 99 + description: Column of the time, when separate. + - in: query + name: referenceLow + schema: + type: integer + minimum: 0 + maximum: 99 + description: 0-based index of the file column this field is read from. + - in: query + name: referenceHigh + schema: + type: integer + minimum: 0 + maximum: 99 + description: 0-based index of the file column this field is read from. + - in: query + name: referenceRange + schema: + description: Column of a printed range such as `4.0-5.6` or `< 200`. + type: integer + minimum: 0 + maximum: 99 + description: Column of a printed range such as `4.0-5.6` or `< 200`. + - in: query + name: panel + schema: + type: integer + minimum: 0 + maximum: 99 + description: 0-based index of the file column this field is read from. + - in: query + name: note + schema: + description: Column of a note; stored encrypted. + type: integer + minimum: 0 + maximum: 99 + description: Column of a note; stored encrypted. + requestBody: + required: true + content: + text/csv: + schema: + type: string + example: |- + Analyte;Value;Unit;Date;Range + HbA1c;5,4;%;01.03.2025;4,0-5,6 + Urine protein;negative;;01.03.2025; + responses: + "200": + description: Per-file and per-row outcome. + content: + application/json: + schema: + $ref: "#/components/schemas/LabsCsvImportEnvelope" + "401": *a1 + "403": + description: "`module.disabled`: the module the import writes to is switched off." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "413": + description: "`import.csv.too_large`: the file is larger than 16 MB." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "415": + description: "`import.csv.content_type`: the body is not `text/csv`." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + /api/import/medications-csv: + post: + tags: + - Import + summary: Import medications from CSV + description: "Body is the raw CSV file (`text/csv`, ≤ 16 MB), read as a stream: a UTF-8 byte-order mark is dropped, the + separator is `,`, `;` or a tab, quoted fields may hold separators and line breaks. Every row is checked on its + own and a refused row is reported by line with a reason, never guessed (a number or date that reads two ways is + refused). Re-uploading the same file writes nothing: every row is reported as a duplicate. `dryRun=1` checks the + whole file without writing and returns the first rows as they would be stored; it spends a separate bucket of 30 + checks an hour, the import itself the shared 5-an-hour `import:` bucket. Owner only; refused with + `module.disabled` when the module is switched off. An unknown query parameter is refused with 422, so a misspelt + `dryRun` never writes. Maps name, dose (as written), daily intake times, an as-needed mark and a start and end + date; every other rhythm is set up afterwards in the app. A row needs times unless it is marked as needed. Each + row is checked against the create route's own schema; a dated window opens a course. Reminders stay off unless + `reminders=1`. A medication the record holds under the same name and dose is a duplicate. At most 500 data + rows." + parameters: + - in: query + name: dryRun + schema: + description: "`1` validates every row and reports what would happen, writing nothing." + default: "0" + type: string + enum: + - "0" + - "1" + - "true" + - "false" + description: "`1` validates every row and reports what would happen, writing nothing." + - in: query + name: header + schema: + description: "`1` when the first row holds column names and is not data." + default: "1" + type: string + enum: + - "0" + - "1" + description: "`1` when the first row holds column names and is not data." + - in: query + name: delimiter + schema: + default: auto + description: Column separator; `auto` detects it from the first line. + type: string + enum: + - auto + - comma + - semicolon + - tab + description: Column separator; `auto` detects it from the first line. + - in: query + name: decimal + schema: + default: dot + description: Decimal mark of the numbers. A number that reads differently in the two conventions (`1,234` with `dot`) is + refused per row as ambiguous. + type: string + enum: + - dot + - comma + description: Decimal mark of the numbers. A number that reads differently in the two conventions (`1,234` with `dot`) is + refused per row as ambiguous. + - in: query + name: dateOrder + schema: + default: iso + description: "`iso` = YYYY-MM-DD[THH:mm[:ss][offset]], `dmy` = DD.MM.YYYY (also / or -), `mdy` = MM/DD/YYYY. Two-digit + years are refused." + type: string + enum: + - iso + - dmy + - mdy + description: "`iso` = YYYY-MM-DD[THH:mm[:ss][offset]], `dmy` = DD.MM.YYYY (also / or -), `mdy` = MM/DD/YYYY. Two-digit + years are refused." + - in: query + name: timezone + schema: + description: IANA zone a date-time without an offset is read in. Defaults to the account's zone. A date without a time + is stored as that calendar date. + type: string + maxLength: 64 + description: IANA zone a date-time without an offset is read in. Defaults to the account's zone. A date without a time + is stored as that calendar date. + - in: query + name: name + schema: + type: integer + minimum: 0 + maximum: 99 + description: Column of the medication name (required). + required: true + description: Column of the medication name (required). + - in: query + name: dose + schema: + type: integer + minimum: 0 + maximum: 99 + description: Column of the dose as written, e.g. `10 mg` (required). + required: true + description: Column of the dose as written, e.g. `10 mg` (required). + - in: query + name: times + schema: + description: "Column of daily intake times (`08:00`, or several: `08:00 20:00`, separated by space, comma, semicolon or + `|`). A medication with times is taken every day at them; any other rhythm is set up afterwards in the + app. A row needs times unless it is marked as needed." + type: integer + minimum: 0 + maximum: 99 + description: "Column of daily intake times (`08:00`, or several: `08:00 20:00`, separated by space, comma, semicolon or + `|`). A medication with times is taken every day at them; any other rhythm is set up afterwards in the app. + A row needs times unless it is marked as needed." + - in: query + name: asNeeded + schema: + description: Column marking an as-needed medication (yes/no). + type: integer + minimum: 0 + maximum: 99 + description: Column marking an as-needed medication (yes/no). + - in: query + name: startsOn + schema: + description: Column of the start date. + type: integer + minimum: 0 + maximum: 99 + description: Column of the start date. + - in: query + name: endsOn + schema: + description: Column of the end date. + type: integer + minimum: 0 + maximum: 99 + description: Column of the end date. + - in: query + name: reminders + schema: + description: "`1` turns reminders on for imported medications that have times. Off by default, so an import never starts + reminding on its own." + default: "0" + type: string + enum: + - "0" + - "1" + description: "`1` turns reminders on for imported medications that have times. Off by default, so an import never starts + reminding on its own." + requestBody: + required: true + content: + text/csv: + schema: + type: string + example: |- + name,dose,times,as needed,start,end + Ramipril,5 mg,08:00,,2025-01-01, + Ibuprofen,400 mg,,yes,, + responses: + "200": + description: Per-file and per-row outcome. + content: + application/json: + schema: + $ref: "#/components/schemas/MedicationsCsvImportEnvelope" + "401": *a1 + "403": + description: "`module.disabled`: the module the import writes to is switched off." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "413": + description: "`import.csv.too_large`: the file is larger than 16 MB." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "415": + description: "`import.csv.content_type`: the body is not `text/csv`." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 /api/import: post: tags: @@ -15739,8 +16688,9 @@ paths: tags: - Labs summary: Fetch a single biomarker - description: Returns the biomarker including its decrypted `context` and, since v1.43, its `aliases`. Cross-user rows - surface as 404. A marker merged into another answers 404 with `meta.mergedInto`. + description: Returns the biomarker including its decrypted `context`, since v1.43 its `aliases`, and since v1.44 its + `trend` over every stored reading. Cross-user rows surface as 404. A marker merged into another answers 404 with + `meta.mergedInto`. parameters: - in: path name: id @@ -22648,11 +23598,9 @@ paths: tags: - Account sharing summary: Read the managed record's integration connection states - description: "Disclosure only: whether each of the record's integrations is connected, when it last succeeded, and its - server-resolved health. There is deliberately no control arm and no credential projection anywhere under this - path — a Guardian cannot connect, disconnect, probe or resume a managed record's provider, because an OAuth flow - cannot safely carry a record selector. Cookie session or Bearer, while acting on a managed record the caller - guards." + description: Whether each of the record's integrations is connected, when it last succeeded, and its server-resolved + health. No credential projection. Connecting and disconnecting an OAuth provider are the two POST routes below + this path. Cookie session or Bearer, while acting on a managed record the caller guards. responses: "200": description: Connection state per provider. @@ -22664,6 +23612,84 @@ paths: "403": *a11 "422": *a4 "429": *a5 + /api/record-settings/integrations/{provider}/connect: + post: + tags: + - Account sharing + summary: Start connecting an OAuth provider for the managed record + description: "Mints the OAuth state for the record the session is acting on, sets it as an httpOnly cookie, and answers + with the provider's consent URL; the client then sends the browser there. A fetch rather than a navigation, so + the record-session fence applies. The state names the managed record: the provider's callback lands the tokens + on it after checking the browser still belongs to a Guardian holding a live MANAGE grant on that record. When + the record has no provider app of its own and the deployment has none, the Guardian's own app is copied onto the + record (audited). Fitbit is not offered: its classic API is retired. Cookie session, while acting on a managed + record the caller guards." + parameters: + - &a54 + name: provider + in: path + required: true + schema: + type: string + enum: + - withings + - whoop + - oura + - polar + - strava + - google-health + description: The OAuth provider. An unrecognised value is a 404, answered before authentication. + responses: + "200": + description: The consent URL to send the browser to. + content: + application/json: + schema: + $ref: "#/components/schemas/ManagedConnectStartEnvelope" + "401": *a1 + "403": *a11 + "404": + description: The path named a provider outside the closed set. Answered before the caller is authenticated. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: Neither the record, the Guardian, nor the deployment has an OAuth app for this provider. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + /api/record-settings/integrations/{provider}/disconnect: + post: + tags: + - Account sharing + summary: Disconnect an OAuth provider from the managed record + description: "Runs the same disconnect the owner of a record runs: the grant is dropped, the provider told where it can + (Withings webhooks, Strava deauthorisation), and the integration parked at `disconnected`. Imported data stays. + Audited against the record with the Guardian as the actor. Cookie session or Bearer, while acting on a managed + record the caller guards." + parameters: + - *a54 + responses: + "200": + description: The provider is disconnected. + content: + application/json: + schema: + $ref: "#/components/schemas/ManagedDisconnectEnvelope" + "401": *a1 + "403": *a11 + "404": + description: The path named a provider outside the closed set, or the record has no connection to it. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 /api/record-settings/{family}: get: tags: @@ -22672,7 +23698,7 @@ paths: description: Returns the named family's resolved settings for the record the session is acting on. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - &a54 + - &a55 name: family in: path required: true @@ -22716,7 +23742,7 @@ paths: ride the audit row. Body capped at 64 KiB. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - *a54 + - *a55 requestBody: required: true content: @@ -22797,8 +23823,9 @@ paths: - $ref: "#/components/schemas/HealthAdminResponse" - $ref: "#/components/schemas/HealthPublicResponse" "503": - description: "Degraded — the database did not answer, or a role this process runs is not up. Same body shape as the 200, - with `status: \"degraded\"`. A restart loop reports this rather than failing to answer, so a monitor can + description: "Degraded — the database did not answer within two seconds, or a role this process runs is not up. Same + body shape as the 200, with `status: \"degraded\"`, and `reason: \"starting\"` while the background role is + still coming up after a start. A restart loop reports this rather than failing to answer, so a monitor can tell 'starting' from 'gone'." content: application/json: @@ -23342,13 +24369,13 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupDocument" - "403": &a55 + "403": &a56 description: Caller is not an admin. `requireAdmin()` is cookie-only, so a Bearer token lands here whatever its scope. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": &a56 + "404": &a57 description: No stored copy with this id. content: application/json: @@ -23422,8 +24449,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "403": *a55 - "404": *a56 + "403": *a56 + "404": *a57 "409": description: A restore of the same account is already queued or running (`meta.errorCode` = `backup.restore.active`, `meta.jobId` names it), the stored copy was replaced by a newer one while it was being checked @@ -23467,7 +24494,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobListResponse" - "403": *a55 + "403": *a56 /api/admin/backups/restores/{jobId}: get: tags: @@ -23490,7 +24517,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobResponse" - "403": *a55 + "403": *a56 "404": description: No restore job with this id. content: @@ -23509,7 +24536,7 @@ paths: request leaves the server. Needs neither the Documents module nor the list: with the list unset, `available` is false and any connection still stored is listed (with `originAllowed: false`) so it can be removed. Cookie session only; not delegable." - security: &a57 + security: &a58 - cookieAuth: [] responses: "200": @@ -23519,7 +24546,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourcesStatusEnvelope" "401": *a1 - "403": &a58 + "403": &a59 description: "Refused before any request to the source: `documents.sources.browserOnly` for a Bearer caller, `sharing.not_permitted` while acting on another person's record (a delegate or guardian never uses the owner's archive), or `module.disabled` when the Documents module is off." @@ -23542,9 +24569,9 @@ paths: `documents.sources.tokenRequired`), so a stored token is never sent to an address it was not saved for. A Papra organization id is letters, digits, `_` and `-`. The token is stored encrypted (AES-256-GCM) and never returned. Ten saves and tests a minute per person. Cookie session only; not delegable." - security: *a57 + security: *a58 parameters: - - &a59 + - &a60 name: system in: path required: true @@ -23583,8 +24610,8 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceConnectionEnvelope" "401": *a1 - "403": *a58 - "404": &a60 + "403": *a59 + "404": &a61 description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. `documents.sources.notFound`: the source has no such document, or (Papra) no such organization, including @@ -23603,7 +24630,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "502": &a61 + "502": &a62 description: "The source could not answer usefully, with the reason in `meta.errorCode` and, when the source answered at all, its HTTP status in `meta.upstreamStatus` (its body is never passed on): `documents.sources.unreachable` (no connection, timeout, or a destination refused at dial time), `documents.sources.redirected` (the source @@ -23622,9 +24649,9 @@ paths: description: Deletes the caller's connection for this system, and its token with it. Works even after the operator removed the origin from the list, and with the Documents module switched off. `disconnected` is false when there was nothing to delete. Cookie session only; not delegable. - security: *a57 + security: *a58 parameters: - - *a59 + - *a60 responses: "200": description: Removed, or there was nothing to remove. @@ -23633,7 +24660,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceDisconnectEnvelope" "401": *a1 - "403": *a58 + "403": *a59 "422": *a4 "429": *a5 /api/documents/sources/{system}/test: @@ -23644,9 +24671,9 @@ paths: description: One request to the source with the stored token. Answers `ok` with the round-trip time and stamps `lastVerifiedAt`, or the reason in `meta.errorCode`. Shares the save's allowance of ten a minute. Cookie session only; not delegable. - security: *a57 + security: *a58 parameters: - - *a59 + - *a60 responses: "200": description: The source answered. @@ -23655,9 +24682,9 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceTestEnvelope" "401": *a1 - "403": *a58 - "404": *a60 - "422": &a62 + "403": *a59 + "404": *a61 + "422": &a63 description: "`documents.sources.originNotAllowed`: the base address's origin is not on the operator's list (checked on every request, so a connection saved earlier is cut off once its origin is removed), or another validation failure." @@ -23666,7 +24693,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "502": *a61 + "502": *a62 /api/documents/sources/{system}/tags: get: tags: @@ -23674,9 +24701,9 @@ paths: summary: Tags of a connected archive description: The source's tags for the picker's filter, the first 250 by name, in the source's own ids. Shares the search allowance of sixty a minute. Cookie session only; not delegable. - security: *a57 + security: *a58 parameters: - - *a59 + - *a60 responses: "200": description: The tags. @@ -23685,11 +24712,11 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceTagsEnvelope" "401": *a1 - "403": *a58 - "404": *a60 - "422": *a62 + "403": *a59 + "404": *a61 + "422": *a63 "429": *a5 - "502": *a61 + "502": *a62 /api/documents/sources/{system}/search: get: tags: @@ -23703,9 +24730,9 @@ paths: under that source key: `new`, `imported` with the vault document's `documentId`, or `deleted` (deleted in HealthLog; importing it again stores nothing). Nothing is stored. Sixty searches a minute, shared with the tag read. Cookie session only; not delegable." - security: *a57 + security: *a58 parameters: - - *a59 + - *a60 - name: q in: query required: false @@ -23746,11 +24773,11 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceSearchEnvelope" "401": *a1 - "403": *a58 - "404": *a60 - "422": *a62 + "403": *a59 + "404": *a61 + "422": *a63 "429": *a5 - "502": *a61 + "502": *a62 /api/documents/sources/{system}/import: post: tags: @@ -23769,9 +24796,9 @@ paths: fetched), including a duplicate. `kind` sets the document type (default OTHER). Automatic AI reading follows the person's own setting. Counts against the person's upload allowance (60 an hour) and storage quota. 201 for a new document, 200 otherwise. Cookie session only; not delegable." - security: *a57 + security: *a58 parameters: - - *a59 + - *a60 requestBody: required: true content: @@ -23829,7 +24856,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceImportEnvelope" "401": *a1 - "403": *a58 + "403": *a59 "404": description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. @@ -23860,7 +24887,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a62 + "422": *a63 "429": description: "`documents.inbound.rateLimited` (the person's hourly upload allowance, with the rate-limit headers) or `documents.inbound.uploadBusy` (too many uploads in progress; `Retry-After: 1`)." @@ -23868,7 +24895,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": *a61 + "502": *a62 /api/symptoms/definitions: get: tags: @@ -23892,7 +24919,7 @@ paths: schema: $ref: "#/components/schemas/ListSymptomDefinitionsEnvelope" "401": *a1 - "403": &a63 + "403": &a64 description: >- `illness.disabled`: the record has the illness module switched off (or the operator turned it off server-wide); symptoms ride that module. @@ -23935,7 +24962,7 @@ paths: schema: $ref: "#/components/schemas/CreateSymptomDefinitionEnvelope" "401": *a1 - "403": *a63 + "403": *a64 "409": *a7 "422": *a4 "429": *a5 @@ -23965,8 +24992,8 @@ paths: schema: $ref: "#/components/schemas/UpdateSymptomDefinitionEnvelope" "401": *a1 - "403": *a63 - "404": &a64 + "403": *a64 + "404": &a65 description: "`symptoms.definition.notFound`: no definition with this id on the record." content: application/json: @@ -24000,8 +25027,8 @@ paths: schema: $ref: "#/components/schemas/DeleteSymptomDefinitionEnvelope" "401": *a1 - "403": *a63 - "404": *a64 + "403": *a64 + "404": *a65 "422": *a4 "429": *a5 /api/symptoms/events: @@ -24050,7 +25077,7 @@ paths: schema: $ref: "#/components/schemas/ListSymptomEventsEnvelope" "401": *a1 - "403": *a63 + "403": *a64 "422": *a4 "429": *a5 post: @@ -24076,8 +25103,8 @@ paths: schema: $ref: "#/components/schemas/CreateSymptomEventEnvelope" "401": *a1 - "403": *a63 - "404": *a64 + "403": *a64 + "404": *a65 "409": *a7 "422": *a4 "429": *a5 @@ -24107,8 +25134,8 @@ paths: schema: $ref: "#/components/schemas/UpdateSymptomEventEnvelope" "401": *a1 - "403": *a63 - "404": &a65 + "403": *a64 + "404": &a66 description: "`symptoms.event.notFound`: no occurrence with this id on the record." content: application/json: @@ -24135,8 +25162,8 @@ paths: schema: $ref: "#/components/schemas/DeleteSymptomEventEnvelope" "401": *a1 - "403": *a63 - "404": *a65 + "403": *a64 + "404": *a66 "422": *a4 "429": *a5 /api/day/index: @@ -24287,7 +25314,7 @@ paths: schema: $ref: "#/components/schemas/TimelineEnvelope" "401": *a1 - "403": &a66 + "403": &a67 description: >- `module.disabled` with `meta.module = timeline`: the record has the timeline switched off (it is opt-in), or the operator turned it off server-wide. @@ -24324,7 +25351,7 @@ paths: schema: $ref: "#/components/schemas/TimelineReadinessEnvelope" "401": *a1 - "403": *a66 + "403": *a67 "422": *a4 "429": *a5 /api/life-events: @@ -24342,7 +25369,7 @@ paths: schema: $ref: "#/components/schemas/LifeEventListEnvelope" "401": *a1 - "403": &a67 + "403": &a68 description: >- Owner-only in v1.42: no share reaches life events, not a `profile` share, a whole-record share or MANAGE, and not a guardian acting for a managed profile. @@ -24382,7 +25409,7 @@ paths: schema: $ref: "#/components/schemas/CreateLifeEventEnvelope" "401": *a1 - "403": *a67 + "403": *a68 "409": *a7 "422": *a4 "429": *a12 @@ -24413,8 +25440,8 @@ paths: schema: $ref: "#/components/schemas/UpdateLifeEventEnvelope" "401": *a1 - "403": *a67 - "404": &a68 + "403": *a68 + "404": &a69 description: No such life event on this account. content: application/json: @@ -24441,8 +25468,8 @@ paths: schema: $ref: "#/components/schemas/DeleteLifeEventEnvelope" "401": *a1 - "403": *a67 - "404": *a68 + "403": *a68 + "404": *a69 "422": *a4 "429": *a5 /api/search: @@ -25948,7 +26975,7 @@ components: type: number const: 2 leaves: - maxItems: 95 + maxItems: 106 type: array items: type: string @@ -26008,7 +27035,7 @@ components: type: number const: 2 leaves: - maxItems: 95 + maxItems: 106 type: array items: type: string @@ -26843,6 +27870,15 @@ components: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY value: type: number measuredAt: @@ -27315,6 +28351,41 @@ components: speedMs?, power?, cadence? }]`, up to 30 000 points). The `samples` series is the strain-engine input for indoor workouts that have no GPS route. Up to 100 workouts per call; nested route geometry capped at 20 000 points. Withings server-to-server callers pass source: WITHINGS and ship no route (Withings reports aggregates only)." + ActivityGoalsPatch: + type: object + properties: + workoutsPerWeek: + description: Workouts per week (Monday to Sunday). `null` clears the goal. + anyOf: + - type: integer + minimum: 1 + maximum: 28 + - type: "null" + activeMinutesPerWeek: + description: "Active minutes per week: the larger of the day's exercise minutes and the day's workout minutes, summed + over the week. `null` clears the goal." + anyOf: + - type: integer + minimum: 10 + maximum: 5000 + - type: "null" + stepsPerWeek: + description: Steps per week. `null` clears the goal. + anyOf: + - type: integer + minimum: 1000 + maximum: 700000 + - type: "null" + stepsPerMonth: + description: Steps per calendar month. `null` clears the goal. + anyOf: + - type: integer + minimum: 1000 + maximum: 3000000 + - type: "null" + additionalProperties: false + description: The goals to change. A key that is present replaces the stored goal (`null` clears it); a key that is + absent stays as it is. Unknown keys are refused. DeviceRegisterRequest: type: object properties: @@ -28524,6 +29595,15 @@ components: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY biomarkerId: type: string minLength: 1 @@ -29023,7 +30103,7 @@ components: type: number const: 2 leaves: - maxItems: 95 + maxItems: 106 type: array items: type: string @@ -29358,6 +30438,14 @@ components: - labsLocalOcrEnabled description: Turn browser-side OCR on or off for lab-report scanning. When on, a text-only provider can still read a paper report because the image is transcribed on the device and only the TEXT is sent. + WorkoutRoutesPatchRequest: + type: object + properties: + storeWorkoutRoutes: + type: boolean + required: + - storeWorkoutRoutes + description: Keep or stop keeping the GPS track of new workouts. Off by default. UseCentralCodexPatchRequest: type: object properties: @@ -30186,6 +31274,15 @@ components: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY description: DB-stored measurement category. v1.4.23 added 7 Apple Health values (HRV, resting HR, active energy, flights, walking/running distance, VO2 max, body temperature). InsightsSettingsPutRequest: @@ -31354,6 +32451,48 @@ components: required: - confirm description: Typed confirmation for erasing the record. No undo. + ModuleListLayoutPutBody: + type: object + properties: + version: + type: number + const: 1 + list: + type: string + enum: + - labs + - illness + - vorsorge + view: + type: string + enum: + - cards + - list + order: + maxItems: 500 + type: array + items: + type: string + minLength: 1 + maxLength: 64 + sortDir: + type: string + enum: + - recentDesc + - recentAsc + - alphaAsc + - alphaDesc + - manual + required: + - version + - list + additionalProperties: false + PreviewFeaturesPatchBody: + type: object + properties: + topics: + type: boolean + additionalProperties: false ConsentPostBody: type: object properties: @@ -36185,10 +37324,11 @@ components: one the native client names; the integration-probe classes (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed` and the per-provider additions) are enumerated in each `/test` operation instead, where the differences can be stated; and a 401 raised by a route checking a - credential of its own carries the generic `http.unauthenticated`. ai — ai.budget.exceeded, - ai.provider.none, ai.record.notPermitted, ai.unavailable, ai.usage.aboveCeiling, - ai.usage.defaultAboveCeiling, ai.usage.invalid, ai.usage.levelTooLow, ai.usage.rateLimited; ai_provider - — ai_provider.invalid, ai_provider.no_fields; aiRuns — aiRuns.failed, aiRuns.notFound, aiRuns.timedOut, + credential of its own carries the generic `http.unauthenticated`. activity — + activity.calendar.future_date, activity.calendar.too_old; ai — ai.budget.exceeded, ai.provider.none, + ai.record.notPermitted, ai.unavailable, ai.usage.aboveCeiling, ai.usage.defaultAboveCeiling, + ai.usage.invalid, ai.usage.levelTooLow, ai.usage.rateLimited; ai_provider — ai_provider.invalid, + ai_provider.no_fields; aiRuns — aiRuns.failed, aiRuns.notFound, aiRuns.timedOut, aiRuns.workerUnavailable; allergy — allergy.invalid; analytics — analytics.invalid_query; anamnesis — anamnesis.auditC.invalid, anamnesis.auditC.notFound, anamnesis.auditC.rateLimited, anamnesis.fact.conflict, anamnesis.fact.currentExists, anamnesis.fact.invalidValue, @@ -36239,7 +37379,8 @@ components: http.unauthenticated, http.unavailable, http.unsupported_media_type; illness — illness.day-log.invalid, illness.disabled, illness.episode.chronic-no-resolve, illness.episode.invalid, illness.episode.invalid-window, illness.episode.parent-not-found, illness.insights.invalid; import — - import.apple_health.busy, import.write_failed; ingest — ingest.rate_limited; insights — + import.apple_health.busy, import.csv.content_type, import.csv.empty, import.csv.too_large, + import.rate_limited, import.write_failed; ingest — ingest.rate_limited; insights — insights.generate.budgetExceeded, insights.generate.outboundScreened, insights.generate.profileScopeChanged, insights.generate.scopeChanged; labs — labs.biomarker.alias.taken, labs.biomarker.create.invalid, labs.biomarker.duplicate, @@ -38796,6 +39937,9 @@ components: medicationListLayoutJson: {} moodTagLayoutJson: {} documentsLayoutJson: {} + moduleListLayoutJson: {} + previewFeaturesJson: {} + activityGoalsJson: {} reportSelectionJson: {} globalExcludedInjectionSites: type: array @@ -38851,6 +39995,8 @@ components: type: boolean labsLocalOcrEnabled: type: boolean + storeWorkoutRoutes: + type: boolean onboardingCompletedAt: anyOf: - type: string @@ -38961,6 +40107,15 @@ components: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY + - AFIB_BURDEN value: type: number valueMin: @@ -39701,6 +40856,15 @@ components: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY + - AFIB_BURDEN - type: "null" biomarkerName: anyOf: @@ -42176,6 +43340,15 @@ components: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY + - AFIB_BURDEN dateKey: type: string pattern: ^\d{4}-\d{2}-\d{2}$ @@ -43354,6 +44527,15 @@ components: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY + - AFIB_BURDEN - type: "null" intervalDays: anyOf: @@ -43566,6 +44748,15 @@ components: - WAIST_TO_HEIGHT - WHO5_SCORE - SCI_SCORE + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY + - AFIB_BURDEN metricSlot: type: - string @@ -48192,6 +49383,10 @@ components: - offset - droppedDuplicates additionalProperties: false + trainingLoad: + description: v1.44 — on the first page (`offset=0`) only, account-wide whatever the filter. Absent on later pages and + older servers. + $ref: "#/components/schemas/WorkoutTrainingLoad" required: - workouts - meta @@ -48268,6 +49463,114 @@ components: - hasRoute - hasHrSeries additionalProperties: false + WorkoutTrainingLoad: + anyOf: + - type: object + properties: + status: + type: string + const: ok + asOf: + type: string + description: The last rated UTC day, at most yesterday; the figures are those of this day. The series runs on to + yesterday, so a later stretch without data shows as unrated days. + ctl: + type: number + description: "Fitness: 42-day exponentially weighted TRIMP." + atl: + type: number + description: "Fatigue: 7-day exponentially weighted TRIMP." + tsb: + type: number + description: "Form: fitness minus fatigue at `asOf`." + daysCovered: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + trainingDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + recentRatedDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: "Rated days of the trailing `recentDays` (42 up to yesterday, or fewer when covered less): a stored + impulse, or activity data without a workout." + recentDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + warmingUp: + type: boolean + description: "True below 42 covered days: both averages start at zero." + series: + type: array + items: + type: object + properties: + day: + type: string + ctl: + type: number + atl: + type: number + tsb: + type: number + rated: + type: boolean + description: "False for a day that could not be rated: no stored impulse and no activity data (steps, heart rate, active + energy), or a workout that was never scored. The averages held through it rather than counting it + as rest. A day with activity data and no workout is rest (impulse 0)." + required: + - day + - ctl + - atl + - tsb + - rated + additionalProperties: false + description: The trailing 90 days, oldest first; `tsb` is form going into the day. + required: + - status + - asOf + - ctl + - atl + - tsb + - daysCovered + - trainingDays + - recentRatedDays + - recentDays + - warmingUp + - series + additionalProperties: false + - type: object + properties: + status: + type: string + const: insufficient + reason: + type: string + enum: + - no_impulse_history + - too_little_history + daysCovered: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + trainingDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - status + - reason + - daysCovered + - trainingDays + additionalProperties: false + description: "v1.44 — the fitness-fatigue model over the stored daily heart-rate training impulse (Banister TRIMP; a day + with activity data but no training is 0, a day with no data or an unscored workout is not rated and the averages + hold through it). Needs 28 days and three training days. TRIMP units: comparable with the person's own past + only." GetWorkoutResponse: type: object properties: @@ -48789,6 +50092,1077 @@ components: - index - status additionalProperties: false + ActivityCalendarEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ActivityCalendar" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ActivityCalendar: + type: object + properties: + period: + type: string + enum: + - week + - month + start: + type: string + format: date + 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])))$ + end: + type: string + format: date + 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])))$ + gridStart: + type: string + format: date + 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])))$ + description: First day drawn (the Monday on or before `start`). + gridEnd: + type: string + format: date + 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])))$ + description: Last day drawn (the Sunday on or after `end`). + today: + type: string + format: date + 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])))$ + timezone: + type: string + description: The IANA zone the days are cut in. + loadType: + anyOf: + - type: string + enum: + - DAY_STRAIN + - CARDIO_LOAD + - STRAIN_SCORE + - type: "null" + description: Which training-load score `load` carries; null when the account has none. + days: + type: array + items: + type: object + properties: + date: + type: string + format: date + 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])))$ + inPeriod: + type: boolean + future: + type: boolean + steps: + type: + - number + - "null" + activeMinutes: + description: The larger of the day's exercise minutes and workout minutes (never their sum). + type: + - number + - "null" + workoutCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + workoutsBySport: + type: object + propertyNames: + type: string + additionalProperties: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + load: + type: + - number + - "null" + level: + type: object + properties: + steps: + type: string + enum: + - none + - unrated + - in_progress + - low + - below + - usual + - above + - high + description: "Where the day sits in the person's own spread over the 84 days before the period: `low` below their 10th + percentile, `below` below the 25th, `above` above the 75th, `high` above the 90th, `usual` + between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with; + `in_progress` = today, which has not ended and is never rated." + activeMinutes: + type: string + enum: + - none + - unrated + - in_progress + - low + - below + - usual + - above + - high + description: "Where the day sits in the person's own spread over the 84 days before the period: `low` below their 10th + percentile, `below` below the 25th, `above` above the 75th, `high` above the 90th, `usual` + between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with; + `in_progress` = today, which has not ended and is never rated." + load: + type: string + enum: + - none + - unrated + - in_progress + - low + - below + - usual + - above + - high + description: "Where the day sits in the person's own spread over the 84 days before the period: `low` below their 10th + percentile, `below` below the 25th, `above` above the 75th, `high` above the 90th, `usual` + between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with; + `in_progress` = today, which has not ended and is never rated." + required: + - steps + - activeMinutes + - load + additionalProperties: false + required: + - date + - inPeriod + - future + - steps + - activeMinutes + - workoutCount + - workoutsBySport + - load + - level + additionalProperties: false + weeks: + type: array + items: + type: object + properties: + start: + type: string + format: date + 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])))$ + end: + type: string + format: date + 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])))$ + steps: + type: + - number + - "null" + activeMinutes: + type: + - number + - "null" + workouts: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + goals: + type: array + items: + $ref: "#/components/schemas/ActivityGoalProgress" + description: The weekly goals for a week that has begun. + required: + - start + - end + - steps + - activeMinutes + - workouts + - goals + additionalProperties: false + baseline: + type: object + properties: + steps: + $ref: "#/components/schemas/ActivityBaselineStats" + activeMinutes: + $ref: "#/components/schemas/ActivityBaselineStats" + load: + $ref: "#/components/schemas/ActivityBaselineStats" + required: + - steps + - activeMinutes + - load + additionalProperties: false + summary: + type: object + properties: + steps: + $ref: "#/components/schemas/ActivityPeriodComparison" + activeMinutes: + $ref: "#/components/schemas/ActivityPeriodComparison" + workouts: + $ref: "#/components/schemas/ActivityPeriodComparison" + load: + $ref: "#/components/schemas/ActivityPeriodComparison" + required: + - steps + - activeMinutes + - workouts + - load + additionalProperties: false + sports: + type: array + items: + type: object + properties: + sportType: + type: string + count: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - sportType + - count + additionalProperties: false + goals: + $ref: "#/components/schemas/ActivityGoals" + goalProgress: + type: array + items: + $ref: "#/components/schemas/ActivityGoalProgress" + available: + type: object + properties: + steps: + type: boolean + activeMinutes: + type: boolean + workouts: + type: boolean + load: + type: boolean + required: + - steps + - activeMinutes + - workouts + - load + additionalProperties: false + previous: + type: string + format: date + 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])))$ + description: A day in the previous period. + next: + anyOf: + - type: string + format: date + 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])))$ + - type: "null" + description: A day in the next period, or null when that lies after today. + required: + - period + - start + - end + - gridStart + - gridEnd + - today + - timezone + - loadType + - days + - weeks + - baseline + - summary + - sports + - goals + - goalProgress + - available + - previous + - next + additionalProperties: false + ActivityGoalProgress: + type: object + properties: + key: + type: string + enum: + - workoutsPerWeek + - activeMinutesPerWeek + - stepsPerWeek + - stepsPerMonth + target: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + value: + type: number + met: + type: boolean + required: + - key + - target + - value + - met + additionalProperties: false + ActivityBaselineStats: + anyOf: + - type: object + properties: + median: + type: number + p10: + type: number + p25: + type: number + p75: + type: number + p90: + type: number + days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Days with a value the spread was computed from. + required: + - median + - p10 + - p25 + - p75 + - p90 + - days + additionalProperties: false + - type: "null" + ActivityPeriodComparison: + type: object + properties: + value: + description: "The period so far: a total (steps, active minutes, workouts) or a mean (load)." + type: + - number + - "null" + daysWithData: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + completedDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Days of the period that have ended (today excluded). + completedValue: + description: "`value` over the completed days only. This, not `value`, is what `usualToDate` is compared with, so a + running period is never judged on today's partial day." + type: + - number + - "null" + usual: + description: "The person's usual full period: the median of the twelve weeks (or, per day, of the six months) before it. + Null with fewer than three earlier periods of data." + type: + - number + - "null" + usualToDate: + description: The usual figure for the completed days (today excluded); equals `usual` once the period is over, null + before the period's first day has ended. + type: + - number + - "null" + referencePeriods: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - value + - daysWithData + - completedDays + - completedValue + - usual + - usualToDate + - referencePeriods + additionalProperties: false + ActivityGoals: + type: object + properties: + workoutsPerWeek: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + activeMinutesPerWeek: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + stepsPerWeek: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + stepsPerMonth: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + required: + - workoutsPerWeek + - activeMinutesPerWeek + - stepsPerWeek + - stepsPerMonth + additionalProperties: false + description: The person's own goals; `null` = not set. There is no default goal. + ActivityGoalsEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ActivityGoals" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ActivityGoalsPatchEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ActivityGoals" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DayProvenanceEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DayProvenance" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DayProvenance: + type: object + properties: + type: + type: string + enum: + - 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 + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY + date: + type: string + format: date + 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])))$ + aggregation: + type: string + enum: + - sum + - mean + ranked: + type: boolean + metricKey: + type: + - string + - "null" + ladder: + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + picked: + anyOf: + - type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + - type: "null" + pickReason: + anyOf: + - type: string + enum: + - only_source + - highest_on_ladder + - name_tiebreak + - not_ranked + description: "`only_source`: one source that day. `highest_on_ladder`: it stands highest on the ladder among the day's + sources. `name_tiebreak`: none of them is on the ladder, the source name decided. `not_ranked`: this + metric has no ladder, every source counts." + - type: "null" + sources: + type: array + items: + type: object + properties: + source: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + rank: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Position on the ladder, 0 = highest; 90 = not on the ladder. + readings: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + value: + description: "The day's value from this source: the day total for a cumulative metric (sleep counts asleep stages only), + the mean of the readings otherwise." + type: + - number + - "null" + min: + type: + - number + - "null" + max: + type: + - number + - "null" + firstAt: + type: string + description: ISO instant of the first reading that day. + lastAt: + type: string + unit: + type: + - string + - "null" + devices: + type: array + items: + type: string + description: Device kinds the readings carry; `unknown` when untagged. + pass: + anyOf: + - type: string + enum: + - lower_on_ladder + - not_on_ladder + - name_tiebreak + - type: "null" + description: Why this source was not the one picked; null for the picked one. + required: + - source + - rank + - readings + - value + - min + - max + - firstAt + - lastAt + - unit + - devices + - pass + additionalProperties: false + required: + - type + - date + - aggregation + - ranked + - metricKey + - ladder + - picked + - pickReason + - sources + additionalProperties: false + SourceComparisonEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/SourceComparison" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + SourceComparison: + type: object + properties: + type: + type: string + enum: + - 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 + - EXERCISE_MINUTES + - STAND_MINUTES + - STAND_HOURS + - MINDFUL_MINUTES + - STATE_OF_MIND_VALENCE + - AFIB_BURDEN + - INDOOR_CO2 + - INDOOR_TEMPERATURE + - INDOOR_HUMIDITY + from: + type: string + format: date + 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])))$ + to: + type: string + format: date + 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])))$ + aggregation: + type: string + enum: + - sum + - mean + ranked: + type: boolean + ladder: + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + unit: + type: + - string + - "null" + sources: + type: array + items: + type: object + properties: + source: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - source + - days + additionalProperties: false + a: + anyOf: + - type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + - type: "null" + b: + anyOf: + - type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + - type: "null" + days: + type: array + items: + type: object + properties: + date: + type: string + format: date + 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])))$ + a: + type: + - number + - "null" + b: + type: + - number + - "null" + picked: + anyOf: + - type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + - type: "null" + required: + - date + - a + - b + - picked + additionalProperties: false + summary: + type: object + properties: + daysA: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + daysB: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + daysBoth: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + meanA: + type: + - number + - "null" + meanB: + type: + - number + - "null" + meanDifference: + type: + - number + - "null" + meanAbsoluteDifference: + type: + - number + - "null" + pickedA: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + pickedB: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - daysA + - daysB + - daysBoth + - meanA + - meanB + - meanDifference + - meanAbsoluteDifference + - pickedA + - pickedB + additionalProperties: false + required: + - type + - from + - to + - aggregation + - ranked + - ladder + - unit + - sources + - a + - b + - days + - summary + additionalProperties: false DeviceRegisterResponse: type: object properties: @@ -52378,6 +54752,222 @@ components: - present additionalProperties: false - type: "null" + segmented: + anyOf: + - type: object + properties: + present: + type: boolean + reason: + type: string + enum: + - no_start + - insufficient_before + - insufficient_after + - fit_failed + level: + type: number + window: + anyOf: + - type: object + properties: + from: + type: string + to: + type: string + endReason: + type: string + enum: + - today + - window + - dose_change + required: + - from + - to + - endReason + additionalProperties: false + - type: "null" + days: + type: object + properties: + before: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + after: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + excludedLowAdherence: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + excludedNotTaking: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - before + - after + - excludedLowAdherence + - excludedNotTaking + additionalProperties: false + adherence: + type: object + properties: + status: + type: string + enum: + - tracked + - not_tracked + floorPct: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + lookbackDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - status + - floorPct + - lookbackDays + additionalProperties: false + levelChange: + anyOf: + - type: object + properties: + estimate: + type: number + ciLow: + type: number + ciHigh: + type: number + standardError: + type: number + includesZero: + type: boolean + required: + - estimate + - ciLow + - ciHigh + - standardError + - includesZero + additionalProperties: false + - type: "null" + slopeChangePerWeek: + anyOf: + - type: object + properties: + estimate: + type: number + ciLow: + type: number + ciHigh: + type: number + standardError: + type: number + includesZero: + type: boolean + required: + - estimate + - ciLow + - ciHigh + - standardError + - includesZero + additionalProperties: false + - type: "null" + preSlopePerWeek: + anyOf: + - type: object + properties: + estimate: + type: number + ciLow: + type: number + ciHigh: + type: number + standardError: + type: number + includesZero: + type: boolean + required: + - estimate + - ciLow + - ciHigh + - standardError + - includesZero + additionalProperties: false + - type: "null" + fit: + anyOf: + - type: object + properties: + n: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + df: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + rSquared: + type: number + residualAutocorrelation: + type: number + hacLag: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: "Newey–West lag applied: the automatic rule, at least 7 (a week of daily carry-over)." + autocorrelationCaveat: + type: boolean + description: "True when the lag-1 residual autocorrelation exceeds 0.5: the carry-over is strong enough that the + intervals may still be too narrow, and the read-out says so." + required: + - n + - df + - rSquared + - residualAutocorrelation + - hacLag + - autocorrelationCaveat + additionalProperties: false + - type: "null" + confounders: + type: array + items: + type: object + properties: + medicationName: + type: string + event: + type: string + enum: + - started + - stopped + - dose_change + - paused + - resumed + date: + type: string + required: + - medicationName + - event + - date + additionalProperties: false + required: + - present + - level + - window + - days + - adherence + - levelChange + - slopeChangePerWeek + - preSlopePerWeek + - fit + - confounders + additionalProperties: false + - type: "null" required: - kind - key @@ -52388,6 +54978,7 @@ components: - series - beforeAfter - levelShift + - segmented additionalProperties: false adherence: type: array @@ -55934,6 +58525,48 @@ components: - data - error additionalProperties: false + GetWorkoutRoutesEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/WorkoutRoutesResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + WorkoutRoutesResponse: + type: object + properties: + storeWorkoutRoutes: + type: boolean + required: + - storeWorkoutRoutes + additionalProperties: false + PatchWorkoutRoutesEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/WorkoutRoutesResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false GetUseCentralCodexEnvelope: type: object properties: @@ -66230,6 +68863,16 @@ components: enum: - up - down + beforeDays: + description: v1.44 — days with a value before the break. + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + afterDays: + description: v1.44 — days with a value from the break on. + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 required: - metric - breakDate @@ -69269,6 +71912,143 @@ components: - deletedRows - models additionalProperties: false + ModuleListLayoutEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ModuleListLayout" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ModuleListLayout: + type: object + properties: + version: + type: number + const: 1 + lists: + type: object + properties: + labs: + $ref: "#/components/schemas/ModuleListPrefs" + illness: + $ref: "#/components/schemas/ModuleListPrefs" + vorsorge: + $ref: "#/components/schemas/ModuleListPrefs" + required: + - labs + - illness + - vorsorge + additionalProperties: false + required: + - version + - lists + additionalProperties: false + description: Per-user presentation of the labs, illness and checkups lists. Display-only; a client that does not render + a list ignores it. + ModuleListPrefs: + type: object + properties: + view: + type: string + enum: + - cards + - list + description: '`cards` renders the card grid, `list` the compact list. Default "list" for labs, "cards" otherwise.' + order: + type: array + items: + type: string + description: Manual order as an id allow-list. Ids missing from the live data are ignored; items missing from the list + follow in the server order. + sortDir: + type: string + enum: + - recentDesc + - recentAsc + - alphaAsc + - alphaDesc + - manual + description: Sort of the labs list; `manual` follows `order`. Default "recentDesc". The other lists ignore it. + saved: + type: boolean + description: True when the server holds a choice for this list; false means every field is a default. + required: + - view + - order + - sortDir + - saved + additionalProperties: false + ModuleListLayoutSavedEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ModuleListLayout" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + PreviewFeaturesEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/PreviewFeatures" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + PreviewFeatures: + type: object + properties: + topics: + type: boolean + description: Topic overview pages above the single-metric Insights pages. Off by default. + required: + - topics + additionalProperties: false + description: Per-user opt-in to preview features, one boolean per flag, every flag off by default. + PreviewFeaturesSavedEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/PreviewFeatures" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false PrivacySummaryEnvelope: type: object properties: @@ -69840,6 +72620,180 @@ components: - data - error additionalProperties: false + LabsCsvImportEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/RecordImportResult" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + RecordImportResult: + type: object + properties: + kind: + type: string + enum: + - labs + - medications + dryRun: + type: boolean + rows: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Data rows read (the header excluded). + valid: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Rows that passed every check. + inserted: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Rows written; in a dry run, the rows that would be. + duplicates: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Rows skipped because the record already holds them, or an earlier row of the file does. A re-upload of the + same file therefore writes nothing. + failed: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Rows refused; each is listed in `errors`. + errors: + type: array + items: + type: object + properties: + line: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: 1-based line of the file. + reason: + type: string + enum: + - missing_value + - invalid_number + - ambiguous_number + - missing_unit + - invalid_date + - invalid_time + - ambiguous_date + - future_date + - text_too_long + - unit_mismatch + - invalid_reference_range + - invalid_times + - missing_times + - invalid_yes_no + - as_needed_with_times + - end_before_start + - invalid_medication + - oversized_row + - row_limit + - duplicate + field: + description: The mapped field at fault. + type: string + required: + - line + - reason + additionalProperties: false + description: Refused and duplicate rows, in file order, at most 500. + errorsTruncated: + type: boolean + markers: + description: "Lab imports only: how the file's names meet the markers." + type: object + properties: + matched: + type: array + items: + type: object + properties: + name: + type: string + description: The name as the file writes it. + marker: + type: string + description: The marker it resolves to. + how: + type: string + enum: + - name + - alias + - catalog + required: + - name + - marker + - how + additionalProperties: false + description: File names that resolve to an existing marker, at most 200. + created: + type: array + items: + type: string + description: Names that become new markers, at most 200. + required: + - matched + - created + additionalProperties: false + preview: + type: array + items: + type: object + propertyNames: + type: string + additionalProperties: + type: + - string + - number + - boolean + - "null" + description: The first 20 valid rows as they will be stored (dry run only). + required: + - kind + - dryRun + - rows + - valid + - inserted + - duplicates + - failed + - errors + - errorsTruncated + - preview + additionalProperties: false + MedicationsCsvImportEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/RecordImportResult" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false JsonImportStatsEnvelope: type: object properties: @@ -71755,7 +74709,7 @@ components: type: object properties: data: - $ref: "#/components/schemas/Biomarker" + $ref: "#/components/schemas/BiomarkerDetail" error: type: "null" meta: @@ -71768,6 +74722,183 @@ components: - data - error additionalProperties: false + BiomarkerDetail: + type: object + properties: + id: + type: string + name: + type: string + unit: + type: string + lowerBound: + type: + - number + - "null" + upperBound: + type: + - number + - "null" + panel: + type: + - string + - "null" + analyteKey: + type: + - string + - "null" + hasContext: + type: boolean + context: + type: + - string + - "null" + hidden: + type: boolean + aliases: + description: v1.43 — other spellings that resolve to this marker (folded in by a merge, chosen at an import, or typed on + the marker), sorted, without the marker's own name. Absent on older servers. + type: array + items: + type: string + createdAt: + type: string + updatedAt: + type: string + trend: + description: v1.44 — absent on older servers. + $ref: "#/components/schemas/BiomarkerTrend" + required: + - id + - name + - unit + - lowerBound + - upperBound + - panel + - analyteKey + - hasContext + - context + - hidden + - createdAt + - updatedAt + additionalProperties: false + BiomarkerTrend: + anyOf: + - type: object + properties: + status: + type: string + const: ok + n: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + firstAt: + type: string + description: YYYY-MM-DD of the first reading. + lastAt: + type: string + description: YYYY-MM-DD of the newest reading. + spanDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + slopePerYear: + type: number + description: Least-squares change per year, in the marker's unit. + slopeCi95: + type: array + prefixItems: + - type: number + - type: number + items: false + minItems: 2 + maxItems: 2 + description: 95 % interval of the slope (Student t at n − 2 df). + pValue: + type: number + fittedNow: + type: number + description: The line's value on `asOf`. + asOf: + type: string + description: "YYYY-MM-DD the line was evaluated on: today, or `lastAt` when `projection` is `stale`." + horizonYears: + type: number + description: "How far ahead a bound may be met and still count as `exits`: three years, or the readings' span when + shorter." + horizonCapped: + type: boolean + description: True when `horizonYears` is the three-year cap, false when the readings' shorter span set it. + projection: + type: string + enum: + - exits + - beyond_horizon + - no_exit + - already_outside + - no_clear_trend + - no_band + - stale + exitDate: + description: "Only for `exits`: the day the straight line meets `exitBound`. A projection, never a forecast." + type: + - string + - "null" + exitBound: + anyOf: + - type: string + enum: + - low + - high + - type: "null" + required: + - status + - n + - firstAt + - lastAt + - spanDays + - slopePerYear + - slopeCi95 + - pValue + - fittedNow + - asOf + - horizonYears + - horizonCapped + - projection + - exitDate + - exitBound + additionalProperties: false + - type: object + properties: + status: + type: string + const: insufficient + reason: + type: string + enum: + - too_few_values + - span_too_short + n: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + spanDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - status + - reason + - n + - spanDays + additionalProperties: false + description: "v1.44 — the least-squares line through every numeric reading of the marker (all stored in the marker's + unit). Needs at least three readings spanning at least 365 days, else `insufficient`. `projection` places the + line against the marker's band: `exits` (with `exitDate`) only when the slope's 95 % interval excludes zero, the + line is inside the band today and reaches a bound within `horizonYears` (three years, or the readings' span when + shorter). `stale` when the newest reading is more than 365 days old: the line is then reported as of `lastAt`, + with no projection. Server-computed; clients render it as given." UpdateBiomarkerResponse: type: object properties: @@ -74243,6 +77374,11 @@ components: type: array items: $ref: "#/components/schemas/MentalHealthAssessment" + trends: + description: v1.44 — one per PHQ-9 / GAD-7 / WHO-5 with at least one administration. Absent on older servers. + type: array + items: + $ref: "#/components/schemas/MentalHealthAssessmentTrend" required: - assessments additionalProperties: false @@ -74292,6 +77428,99 @@ components: diagnosis). `item9Flagged` is true when the PHQ-9 self-harm item was answered > 0 — the client MUST show crisis resources when it is. Raw per-item answers are intentionally absent from list rows; the by-id detail read returns them decrypted. + MentalHealthAssessmentTrend: + type: object + properties: + instrument: + type: string + enum: + - PHQ9 + - GAD7 + - WHO5 + n: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + threshold: + type: number + description: "Meaningful-change threshold in reported points: PHQ-9 5, GAD-7 4, WHO-5 10 (0–100 scale)." + higherIsBetter: + type: boolean + first: + type: object + properties: + score: + type: number + takenAt: + type: string + required: + - score + - takenAt + additionalProperties: false + previous: + anyOf: + - type: object + properties: + score: + type: number + takenAt: + type: string + required: + - score + - takenAt + additionalProperties: false + - type: "null" + latest: + type: object + properties: + score: + type: number + takenAt: + type: string + required: + - score + - takenAt + additionalProperties: false + changeFromPrevious: + type: + - number + - "null" + changeFromFirst: + type: + - number + - "null" + sincePrevious: + anyOf: + - type: string + enum: + - improved + - worsened + - within_threshold + - type: "null" + sinceFirst: + anyOf: + - type: string + enum: + - improved + - worsened + - within_threshold + - type: "null" + required: + - instrument + - n + - threshold + - higherIsBetter + - first + - previous + - latest + - changeFromPrevious + - changeFromFirst + - sincePrevious + - sinceFirst + additionalProperties: false + description: v1.44 — the latest total against the previous and the first administration, classified against the + instrument's meaningful-change threshold in its own direction. A change below the threshold is + `within_threshold`. Server-computed over the whole history, not the page. CreateMentalHealthAssessmentEnvelope: type: object properties: @@ -79471,6 +82700,8 @@ components: - vo2max - cardio_age - resilience + - workout + - heartrate hasToken: description: Nightscout only. type: boolean @@ -83528,10 +86759,90 @@ components: - integrations additionalProperties: false description: Whether the managed record's integrations are connected and how each is doing (`health`), and nothing else. - Deliberately no control and no credential projection — OAuth and provider state cannot safely carry a record - selector, so a Guardian can see that a pipe exists but cannot connect, disconnect, probe or resume it from here. - A provider with no real credential reads `disconnected` even when its ledger row says otherwise, because a - synthesised healthy ledger is not proof of a connection. + No credential projection. A Guardian connects or disconnects an OAuth provider for the record through `POST + /api/record-settings/integrations/{provider}/connect` and `/disconnect`. A provider with no real credential + reads `disconnected` even when its ledger row says otherwise, because a synthesised healthy ledger is not proof + of a connection. + ManagedConnectStartEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ManagedConnectStartResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ManagedConnectStartResponse: + type: object + properties: + recordId: + type: string + provider: + type: string + enum: + - withings + - whoop + - oura + - polar + - strava + - google-health + authorizationUrl: + type: string + description: The provider's consent URL, with the state the record owns. + required: + - recordId + - provider + - authorizationUrl + additionalProperties: false + description: Where to send the browser to connect a provider for the managed record. + ManagedDisconnectEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ManagedDisconnectResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ManagedDisconnectResponse: + type: object + properties: + recordId: + type: string + provider: + type: string + enum: + - withings + - whoop + - oura + - polar + - strava + - google-health + disconnected: + type: boolean + const: true + required: + - recordId + - provider + - disconnected + additionalProperties: false + description: The provider is no longer connected to the managed record. GetRecordSettingsFamilyEnvelope: type: object properties: @@ -84031,14 +87342,18 @@ components: when the process serves web, and the worker loop is running when the process runs the worker. `degraded` on any of those failing." HealthReason: - description: "Present only when the process refuses to serve for a reason a person has to fix. - `encryption_key_mismatch`: the configured encryption key cannot open the data in this database (checked at - boot). Every other route then answers 503 with `meta.errorCode` `encryption.key_mismatch`, and background jobs - are not started. Fix: restore the original key, point the server at the matching database, or start with an - empty one; then restart." + description: "Why a `degraded` process is degraded, when it is something other than a component failure. + `encryption_key_mismatch`: the process refuses to serve for a reason a person has to fix; the configured + encryption key cannot open the data in this database (checked at boot). Every other route then answers 503 with + `meta.errorCode` `encryption.key_mismatch`, and background jobs are not started. Fix: restore the original key, + point the server at the matching database, or start with an empty one; then restart. `starting`: the database + answers but the background role (the pg-boss worker, or the producer of a web-only process) is still coming up + after a start. Transient; nothing to fix, poll again. The probe answers at once while it lasts rather than + waiting for the start to finish." type: string enum: - encryption_key_mismatch + - starting HealthPublicResponse: type: object properties: diff --git a/docs/contributing/ui-standards.md b/docs/contributing/ui-standards.md new file mode 100644 index 000000000..d0c0f576e --- /dev/null +++ b/docs/contributing/ui-standards.md @@ -0,0 +1,184 @@ +# UI standards + +The binding reference for building or touching any surface of the web app. The rules are derived from the primitives' own contracts (`src/components/ui/card.tsx`, `tile-header.tsx`, `page-header.tsx`, `settings/_card-header.tsx`, `layout/auth-shell.tsx`) and from a review of every surface against them. When a rule here conflicts with an existing file, the file is drift: fix it when you touch it, don't copy it. Where an older code comment or [`docs/ui-guidelines.md`](../ui-guidelines.md) says otherwise, this document wins. A deviation needs a written reason in the pull request. + +--- + +## 1. Card anatomy + +**The Card is gap-based, not padding-based** (`src/components/ui/card.tsx:24`): `flex flex-col gap-4 rounded-xl border py-4 md:gap-6 md:py-6`; header/content/footer carry `px-4 md:px-6`. The header→body distance comes from the **parent flex gap**, never from slot padding. + +- FORBIDDEN: `pb-*` on `CardHeader`, `pt-*` on `CardContent`. Under the gap model these add on top of the gap (or are no-ops) and invert their intent. Want a tighter card? Override the gap on the Card itself: ``. +- Two densities, nothing else: + - `default`: Card as shipped: `py-4 md:py-6`, `gap-4 md:gap-6`. + - `compact`: `` (stat strips, assessment blocks, small tiles); resolves to `gap-2 py-3 md:gap-2 md:py-4`. Never spell it as a class list: the hand-written `gap-2 py-3 md:py-4` kept the base `md:gap-6` and opened 24 px on desktop (v1.43.8; `card-density.test.tsx` refuses the class list on a Card). + - No `md:py-5` half-steps. +- Radius: cards are `rounded-xl` (tracks `--radius`). `rounded-md` = controls, `rounded-lg` = inner tiles, `rounded-full` = pills. `rounded-2xl` only as a deliberately marked identity surface (Coach hero/input). +- Hand-rolled `bg-card … border … p-N` shells are legacy. New surfaces compose `` (or `SettingsCard` in Settings). If a hand-rolled shell must exist, its padding is exactly `p-4 md:p-6` or `p-3` (dense tile), never `p-5`, never a static `p-6`, never a `sm:` step (`SettingsCard`'s docblock explains why the breakpoint is `md:`). +- **Insights overview sections use `InsightSectionCard`** (`src/components/insights/insight-section-card.tsx`). It composes ``, so the frame is the card contract, and pins the section's inner rhythm at one step (`gap-3`); the six hand-rolled copies it replaced had drifted to three different rhythms in the same column. A section whose body is an edge-to-edge divided list (rows owning their own inset) passes `flush`; everything else takes the padded body. Do not hand-roll a section shell beside it. +- **The dashboard/insights hero band is a sanctioned identity surface.** Its translucent `bg-card/65 … backdrop-blur-sm` tiles are deliberate, not drift: the band reads as one glass surface behind the page's opening statement. It stays out of the shell sweep. Everything below the band is a plain card. + +### Header patterns; exactly three, pick by surface + +| Surface | Primitive | Contract | +| ---------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Page title | `PageHeader` (`ui/page-header.tsx`) | H1 `text-2xl font-bold tracking-tight`, description `text-muted-foreground text-sm`, `space-y-1.5`, **no icon beside the H1**, optional backLink + actions slot | +| Settings card | `SettingsCard` + `SettingsCardHeader` (`settings/_card-header.tsx`) | icon `text-muted-foreground h-5 w-5`, title `h2 text-lg font-semibold`, status slot top-right, description muted below | +| Insights / dashboard / module tile | `Card` + `TileHeader` (`insights/tile-header.tsx`) | icon **`text-foreground` `h-5 w-5 shrink-0`**, title `CardTitle text-base`, `flex items-center gap-2`, optional `right` slot | + +**Icon colour rule (both header contracts):** header icons are `text-foreground` (tiles) or `text-muted-foreground` (settings). Never `text-primary`, `text-info`, or any accent; primary/purple stays reserved for actions and highlight states. Pass the Lucide component (`icon={Target}`), never a pre-sized node; the primitive owns size and colour. + +The gaps this section used to list are closed: `TileHeader` carries a `size="sm"` variant for compact tiles and an optional `icon` for icon-less headers, and `InsightSectionCard` is the overview container mirroring `SettingsCard`. Use them; do not invent a fourth header language. + +## 2. Spacing scale + +> **Scroll height is shell-owned.** Only `AuthShell` owns the scroll container's height and bottom padding (`pb-20` wrapper + ``). Pages/sections never add viewport-height reserves (`min-h-dvh`, `min-h-[calc(100dvh-…)]`), `h-full` chains, or bottom gutters inside the scrolling `
`; nested reserves stack on the shell budget and scroll past the last card. One sanctioned exception: the settings-shell grid floor (budget-derived, documented; retune it when shell paddings change). Lists that need their own scrolling use `max-h-* overflow-y-auto overscroll-contain`. Guard: `e2e/settings-overscroll.spec.ts`. + +- **Allowed steps:** `1 / 1.5 / 2 / 2.5 / 3 / 3.5 / 4 / 6 / 8 / 10`. `2.5` and `3.5` are allowed: the medication card's rhythm is built on them, and the in-between steps earn their keep in dense card interiors. `5` (20 px), as in `py-5` or `p-5`, is not allowed; replace it when you touch a file that still has it. `7` (28 px) is not allowed either: it was the old Settings and Admin `pl-7` body gutter, which is gone (§13). +- **Page frame:** exactly one authenticated container, owned by `AuthShell` (`layout/auth-shell.tsx:318`): `mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6`. Never re-implement it in a page; full-bleed pages opt out via a shell flag, not negative margins. Prose pages (about/privacy/legal): `mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12`. +- **Page rhythm:** root of every page is `space-y-6`. Grouped card sections: `space-y-10` between groups, `space-y-4` inside a group (notifications/integrations pattern). Insights sub-pages join `space-y-6`. +- **Left reading edge:** page-level text (H1, explainer, section headings) sits at the container edge. Only surfaces that paint a visible border/background may inset their content (`px-4 md:px-6`). A block without a border must not carry horizontal padding. +- **Grids:** grids **of** cards/tiles = `gap-4`; value read-outs **inside** one card = `gap-x-3 gap-y-2`; form field grids = `gap-4`. +- **Anchors:** `scroll-mt-28`, single token (31-to-1 in the tree already). + +## 3. Text colour rules + +The base tokens are AA-tuned in both themes (`--muted-foreground`: 5.0–6.4:1, documented in `globals.css`). The system is two tiers: + +- **`text-foreground` = content.** Everything the user came to read: values, chart labels the reading depends on, AI narrative bodies, consent/safety copy, and **all user-entered data** (notes, journal text, free-text fields). User data is never muted. +- **`text-muted-foreground` = meta.** Timestamps, units, provenance, counts, placeholders, one-sentence descriptions under a heading. Heading-in-foreground + short-description-in-muted is the app-wide convention (CardDescription, PageHeader, SettingsCardHeader); keep it; do not "fix" it to foreground. +- **Description slots hold exactly one meta sentence, guideline ≤ 120 characters.** One sentence, one full stop, no paragraph stack. 120 characters is the guideline, not a hard cut: it is the p75 of the descriptions that already read well, and a description that needs 130 characters to stay true is fine while a two-sentence one never is. Multi-sentence explainer prose goes in the body as `

` (foreground), not into a muted description slot; if the body would not carry it either, the sentence is not needed on the surface at all and belongs in the docs. The description slot renders a single text run; it deliberately owns no vertical rhythm of its own, so a call site cannot stack paragraphs into it. +- **One statement, one string.** A section's page subtitle and its card description must not say the same thing twice on one screen. When a section owns a single card, the subtitle carries the statement and the card header drops its description. Same rule for a field label and its field description: the label names the field, the description adds what the label cannot. +- **FORBIDDEN: opacity modifiers on muted text.** `text-muted-foreground/70` ≈ 2.9–3.9:1 and `/80` ≈ 3.4–4.2:1, below AA, and they silently undo the token tuning. Full `text-muted-foreground` IS the de-emphasis tier; a further tier is expressed via size/weight, never via alpha. `text-foreground/80` is safe (≥6.7:1) and allowed. `/60` is icon-only (3:1 graphics floor). +- **Disabled look:** `opacity-50` on the container + `aria-disabled`, not colour dimming on the text node. +- **Size floor:** muted text is never smaller than `text-xs`. Below that, the one legal step is the `text-2xs` token (0.6875rem, minted in `globals.css`) and it is for chips, badges, and tabular-nums micro-labels with verified contrast, never for muted body or meta text. Arbitrary sizes (`text-[0.65rem]`, `text-[11px]`) are drift; chart-axis `text-[10px]`/`text-[11px]` stays tolerated as documented. +- **Status colours** carry meaning, not decoration: `text-success` / `text-warning` / `text-info` / `text-destructive` (+ `bg-*/10` washes). All four are AA-retuned per theme. + +## 4. Colour token rules + +- **Semantic tokens only** in components: `--foreground/--muted-foreground/--primary/--success/--warning/--info/--destructive/--chart-1..5/--border/--card`. The light theme remaps them wholesale; anything outside the token layer is invisible to a retune. +- **No raw `dracula-*` utilities in new code.** Only `--dracula-yellow` has a light override; `text-dracula-green` on a light card is 1.4:1. Status meaning → semantic token; brand identity (Coach gradient) → a named `--brand-*` token with per-theme values. +- **No hex in JS props.** Chart colours are passed as `var(--token)` strings, never `color="#22c55e"`. Data series use the `--chart-1..5` tokens (`color="var(--chart-2)"`); status tokens (`--success`, `--warning`, `--destructive`, `--info`) only where the colour carries meaning, such as a target band, time in range or a mood scale (v1.42). Recharts `stroke`/`fill` likewise. This is the known lint blind spot; review for it explicitly until the rule covers it. +- **No arbitrary-value colours** (`bg-[#…]`, `text-[oklch(…)]`). +- Exemptions (documented at the definition site): `global-error.tsx` (renders without the stylesheet), the `themeColor` meta, the QR quiet-zone `bg-white`. + +## 5. Typography scale + +| Tier | Classes | Where | +| ------------------------ | ------------------------------------------------------------------- | --------------------------------------------------------------- | +| App H1 | `text-2xl font-bold tracking-tight` | every module page, via `PageHeader`: weight is **bold**, always | +| Settings card title | `text-lg font-semibold` | via `SettingsCardHeader` | +| Tile title | `text-base` on `CardTitle` (`font-semibold` from the primitive) | via `TileHeader` | +| Chip / badge micro-label | `text-2xs` (11 px) | `TagChip`, `Badge`, tabular-nums counters: never muted prose | +| Section label | `text-xs font-medium uppercase tracking-wide text-muted-foreground` | list group headings | +| Body | `text-sm` (foreground) | default content | +| Meta | `text-xs text-muted-foreground` | timestamps, units, hints: the floor | +| Prose-page H1 | `text-3xl md:text-4xl font-bold` | about/privacy/landing tier only | + +No `text-[9px]`. No off-scale sizes (`text-[28px]`); round to the neighbouring step. Icon sizes come from the five-step vocabulary `size-3 / 3.5 / 4 / 5 / 6`; prefer `size-N` over `h-N w-N` (identical CSS, one spelling). + +## 6. Component mapping; when to use which primitive + +| Situation | Use | Never | +| --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Page title + description + actions | `PageHeader`: `headingRef` + `focusable` for a title that takes focus after navigation, `titleAccessory` for a glyph or badge beside the H1 (outside its name) | hand-rolled `text-2xl font-bold` H1 blocks (`page-header-h1-guard.test.ts` lists where another H1 is the point: heroes, sign-in, error and prose pages, first-run steps) | +| A second copy of the page title in the same shell (mobile above the nav strip, desktop inside the grid) | `PageHeader headingAs="div"`: one real `

`, one `role="heading" aria-level={1}` | a hand-rolled `role="heading"` div beside a hand-rolled `

` | +| Settings card | `SettingsCard` + `SettingsCardHeader` + `FieldGroup` | raw `Card` in Settings sections | +| Heading above a group of cards (Insights overview, Settings groups) | `SectionHeading` (`ui/section-heading.tsx`) | a `SettingsCardHeader` with no card under it, or a hand-rolled `h2` | +| Action row in a Settings / Admin card | `SettingsCardActions` | a hand-rolled `flex justify-end gap-2` div | +| Admin mini stat tile | `StatTile` (`admin/_stat-tile.tsx`) | a hand-rolled `bg-muted/NN rounded-lg p-3` box | +| Insights/dashboard tile | `Card` + `TileHeader` | hand-rolled icon+title rows, `bg-card rounded-xl border p-4` divs | +| Insights overview section | `InsightSectionCard` | a hand-rolled section shell beside it | +| Neutral label chip on a card header | `TagChip` | a hand-copied `bg-muted … rounded-full` literal, or a `Badge` (that means status) | +| Read-query failed (`isError` on a primary / list surface) | `QueryErrorCard` with `error={query.error}` and `onRetry={refetch}` | bare `text-destructive` paragraphs without retry; a card without `error` | +| Read-query failed inside a dense metric strip, tile or supplementary block, where the tall card would dominate the geometry | `QueryErrorRow` with `error={query.error}` and `onRetry={refetch}` | a hand-rolled row; a third read-failure primitive (`ReadFailureRow` was one and is gone) | +| Read-query failed inside a card that already has its frame and header (a tile body, a settings card section) | `QueryErrorRow inset`: keeps the card and its title, no second border | `QueryErrorCard` inside a card (card in card); replacing the card and its header with the error | +| Read-query failed on a chart body | `ChartErrorState` with `error={query.error}`: same decision as the other two | either of the above | +| A list the server capped (`meta.truncated`) | `ListCapNotice` under the list, read through `apiGetList` / `apiPostList` | a hand-written "showing the latest N" line; reading `data.truncated` | +| Mutation error inside a form/sheet | inline `role="alert"` text | `QueryErrorCard` (wrong shape) | +| Honest no-data state | `EmptyState` | reusing error copy: a failed query must never fall through to "no data yet" | +| Modal reachable on mobile | `ResponsiveSheet` (bottom sheet on phones) | raw `Dialog` (admin/desktop-only surfaces excepted) | +| Date / time entry | `DateField` / `TimeField` / `DateTimeField` | native `type="date | time | datetime-local"` inputs (browser-locale trap, v1.25.4) | +| Small text action inside a line (Open schedule, Select all, View all, Retry, Learn more) | `InlineAction` (`ui/inline-action.tsx`): tone `primary` / `muted` / `foreground`, size `xs` / `sm`, `asChild` for a `Link`; owns the focus ring and `pointer-coarse:min-h-11` | a hand-rolled underlined ` +

+ {periodLabel} +

+ + + + {ratedOptions.length > 1 && metric ? ( + ({ + value: m, + label: t(`activityCalendar.metric.${m}`), + }))} + /> + ) : null} + + {!data.available.steps && + !data.available.activeMinutes && + !data.available.workouts && + !data.available.load ? ( + + ) : ( +
+
+ {weekdays.map((label, i) => ( + + {label} + + ))} + {weeklyGoalsSet && period === "month" ? ( + + + {t("activityCalendar.weekGoalsColumn")} + + + ) : null} +
+ {rows.map((week, rowIndex) => { + const weekRow = data.weeks[rowIndex]; + const allMet = + weekRow && weekRow.goals.length > 0 + ? weekRow.goals.every((g) => g.met) + : null; + return ( +
+ {week.map((day) => { + const level = metric ? day.level[metric] : "none"; + const value = + metric && !day.future ? day[metric] : null; + return ( +
+ +
+ ); + })} + {weeklyGoalsSet && period === "month" ? ( + + {allMet ? ( + + ) : allMet === false ? ( + + {t("activityCalendar.weekGoalsNotMet")} + + ) : null} + + ) : null} +
+ ); + })} +
+ )} + + {metric ? ( +
+ {t("activityCalendar.legend")} + {LEGEND_LEVELS.map((level) => ( + + + ))} + {data.baseline[metric] === null ? ( + + {t("activityCalendar.noBaseline")} + + ) : null} +
+ ) : null} + +
    + {(["steps", "activeMinutes", "workouts", "load"] as const) + .filter((m) => data.available[m]) + .map((m) => { + const s = data.summary[m]; + // Compared over completed days only: today has not ended. + const delta = + s.completedValue !== null && s.usualToDate + ? (s.completedValue - s.usualToDate) / s.usualToDate + : null; + const running = + data.today >= data.start && data.today <= data.end; + return ( +
  • +
    +

    + {t(`activityCalendar.summary.${m}`, { + value: + s.value === null ? "–" : formatMetric(m, s.value), + })} +

    +

    + {s.usual !== null && s.completedDays === 0 + ? t("activityCalendar.summary.firstDay") + : s.usualToDate === null + ? t("activityCalendar.summary.noUsual") + : running + ? t( + "activityCalendar.summary.usualThroughYesterday", + { + value: + s.completedValue === null + ? "–" + : formatMetric(m, s.completedValue), + usual: formatMetric(m, s.usualToDate), + }, + ) + : t("activityCalendar.summary.usual", { + value: formatMetric(m, s.usualToDate), + })} +

    +
    + {delta !== null ? ( + + {`${delta > 0 ? "+" : ""}${fmt.percent(delta)}`} + + ) : null} +
  • + ); + })} +
+ + {data.sports.length > 0 ? ( +
+ {data.sports.map((s) => ( + + {t("activityCalendar.sportCount", { + sport: sportLabel(s.sportType), + count: fmt.integer(s.count), + })} + + ))} +
+ ) : null} + +
+ {data.goalProgress.map((g) => ( +
+
+ + + + {t("activityCalendar.goalOf", { + value: fmt.integer(Math.round(g.value)), + target: fmt.integer(g.target), + })} + +
+ +
+ ))} + {!capabilities.inSharedRecord ? ( + { + setGoalsOpenCount((n) => n + 1); + setGoalsOpen(true); + }} + > + {data.goalProgress.length > 0 || weeklyGoalsSet + ? t("activityCalendar.editGoals") + : t("activityCalendar.setGoals")} + + ) : null} +
+ + )} + + {data && !capabilities.inSharedRecord ? ( + + ) : null} + + ); +} diff --git a/src/components/activity/activity-goals-sheet.tsx b/src/components/activity/activity-goals-sheet.tsx new file mode 100644 index 000000000..682489577 --- /dev/null +++ b/src/components/activity/activity-goals-sheet.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { useSaveActivityGoals } from "@/hooks/use-activity-calendar"; +import { + ACTIVITY_GOAL_BOUNDS, + ACTIVITY_GOAL_KEYS, + type ActivityGoalKey, +} from "@/lib/activity/goal-bounds"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; + +type Goals = Record; +type Draft = Record; + +function toDraft(goals: Goals): Draft { + return Object.fromEntries( + ACTIVITY_GOAL_KEYS.map((k) => [ + k, + goals[k] === null ? "" : String(goals[k]), + ]), + ) as Draft; +} + +/** + * Parse one field. Blank clears the goal; anything else must be a whole + * number inside the bounds the server enforces. Digits only, so a grouping + * separator typed by habit ("10.000") is refused rather than misread. + */ +export function parseGoalField( + key: ActivityGoalKey, + raw: string, +): { ok: true; value: number | null } | { ok: false } { + const trimmed = raw.trim(); + if (trimmed === "") return { ok: true, value: null }; + if (!/^\d+$/.test(trimmed)) return { ok: false }; + const value = Number(trimmed); + const { min, max } = ACTIVITY_GOAL_BOUNDS[key]; + if (value < min || value > max) return { ok: false }; + return { ok: true, value }; +} + +/** + * Set or clear the activity goals. Every goal is optional; an empty field is + * no goal. There is no suggested number: the goal is the person's own. + * + * The draft starts from `goals` when the sheet mounts; the caller remounts it + * on every open (a `key`), so a reopened sheet never shows a stale draft. + */ +export function ActivityGoalsSheet({ + open, + onOpenChange, + goals, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + goals: Goals; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const save = useSaveActivityGoals(); + const [draft, setDraft] = useState(() => toDraft(goals)); + const [invalid, setInvalid] = useState([]); + + const submit = () => { + const bad: ActivityGoalKey[] = []; + const patch: Partial = {}; + for (const key of ACTIVITY_GOAL_KEYS) { + const parsed = parseGoalField(key, draft[key]); + if (!parsed.ok) bad.push(key); + else patch[key] = parsed.value; + } + setInvalid(bad); + if (bad.length > 0) return; + save.mutate(patch, { onSuccess: () => onOpenChange(false) }); + }; + + return ( + + {t("common.save")} + + } + > +
{ + e.preventDefault(); + submit(); + }} + > + {ACTIVITY_GOAL_KEYS.map((key) => { + const id = `activity-goal-${key}`; + const { min, max } = ACTIVITY_GOAL_BOUNDS[key]; + const isInvalid = invalid.includes(key); + return ( +
+ + + setDraft((d) => ({ ...d, [key]: e.target.value })) + } + /> + {isInvalid ? ( + + ) : null} +
+ ); + })} + {save.isError ? ( +

+ {t("activityCalendar.goalsSheet.saveError")} +

+ ) : null} +
+
+ ); +} diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index 69f00d639..09bd986b8 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -61,8 +61,19 @@ export interface SystemStatus { buildTime: string; startTime: string; database: string; - /** The process's heap ceiling; `low` when it is below what a long-used account needs. */ - memory: { heapLimitMb: number; low: boolean }; + /** + * The process's heap ceiling; `low` when it is below what a long-used + * account needs. `containerLimitMb` is the container's memory limit + * (`null` when there is none or it cannot be read), `belowFloor` when it is + * under the supported floor of `floorMb`. + */ + memory: { + heapLimitMb: number; + low: boolean; + containerLimitMb: number | null; + floorMb: number; + belowFloor: boolean; + }; worker: WorkerStatus; /** * Queues whose jobs exhausted their retries recently. `null` means the diff --git a/src/components/admin/system-status-section.tsx b/src/components/admin/system-status-section.tsx index 8f520e46c..c23d4c0d9 100644 --- a/src/components/admin/system-status-section.tsx +++ b/src/components/admin/system-status-section.tsx @@ -164,11 +164,22 @@ export function SystemStatusSection() { icon={MemoryStick} label={t("admin.heapLimitLabel")} value={ - status.memory.low - ? t("admin.heapLimitLow", { mb: status.memory.heapLimitMb }) - : t("admin.heapLimitValue", { mb: status.memory.heapLimitMb }) + status.memory.belowFloor && status.memory.containerLimitMb + ? t("admin.memoryBelowFloor", { + mb: status.memory.containerLimitMb, + floor: status.memory.floorMb, + }) + : status.memory.low + ? t("admin.heapLimitLow", { mb: status.memory.heapLimitMb }) + : t("admin.heapLimitValue", { + mb: status.memory.heapLimitMb, + }) + } + tone={ + status.memory.belowFloor || status.memory.low + ? "warning" + : undefined } - tone={status.memory.low ? "warning" : undefined} /> ", () => { expect(html).not.toContain("Current smoker"); }); + it("shows the medication effect only beside the medication list", () => { + const report = makeReport({ + medicationEffects: [ + { + medicationName: "Named effect drug", + startsOn: "2026-01-05", + targets: [ + { + type: "BLOOD_PRESSURE_SYS", + effect: { + present: false, + reason: "insufficient_after", + level: 0.95, + window: null, + days: { + before: 40, + after: 3, + excludedLowAdherence: 0, + excludedNotTaking: 0, + }, + adherence: { + status: "tracked", + floorPct: 80, + lookbackDays: 14, + }, + levelChange: null, + slopeChangePerWeek: null, + preSlopePerWeek: null, + fit: null, + confounders: [], + }, + }, + ], + }, + ], + }); + const leaves = ["MEDICATION_EFFECT", "BLOOD_PRESSURE_SYS"] as const; + const est = { + estimate: -5, + ciLow: -7, + ciHigh: -3, + standardError: 1, + includesZero: false, + }; + const fitted = makeReport({ + medicationEffects: [ + { + ...report.medicationEffects![0], + targets: [ + { + type: "BLOOD_PRESSURE_SYS", + effect: { + ...report.medicationEffects![0].targets[0].effect, + present: true, + reason: undefined, + levelChange: est, + slopeChangePerWeek: est, + preSlopePerWeek: est, + window: { + from: "2025-10-07", + to: "2026-01-20", + endReason: "today", + }, + }, + }, + ], + }, + ], + }); + expect( + render(fitted, { + selection: selectionFromLeaves([...leaves, "MEDICATION_LIST"]), + }), + ).toContain("This window can reach beyond the report period."); + expect( + render(report, { + selection: selectionFromLeaves([...leaves, "MEDICATION_LIST"]), + }), + ).toContain("Named effect drug"); + expect( + render(report, { selection: selectionFromLeaves([...leaves]) }), + ).not.toContain("Named effect drug"); + }); + it("names both windows when a lab's report and the saved band disagree", () => { // Two windows that disagree about the same number, and a card showing one // of them is a partial answer. The PDF settles this with a footnote under diff --git a/src/components/clinician/activity-effect-sections.tsx b/src/components/clinician/activity-effect-sections.tsx new file mode 100644 index 000000000..d6a2629f1 --- /dev/null +++ b/src/components/clinician/activity-effect-sections.tsx @@ -0,0 +1,209 @@ +/** + * The two leaves the v1.44 report added, on the clinician page: the per-sport + * workout summary and the medication effect around each start. Content and + * order follow the PDF blocks in `doctor-report-pdf/activity-effect-sections`, + * and both read the same aggregated payload. + * + * A pure server component: no client hooks, no markdown — every value renders + * as escaped React text. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import type { DoctorReportData } from "@/lib/doctor-report-data"; +import { reportStatUnit } from "@/lib/doctor-report/stat-display"; +import { resolveGlucoseUnit } from "@/lib/glucose"; +import { formatEstimate } from "@/lib/medications/efficacy/segmented-format"; +import { reportMeasurementLabelKey } from "@/lib/report-selection/catalogue"; +import { reportSportLabel } from "@/lib/doctor-report/sport-label"; +import { + LeafSection, + StatRow, + type LeafScope, + type Translate, +} from "./report-sections"; + +const REASON_KEYS = { + no_start: "doctorReport.medicationEffect.reasonNoStart", + insufficient_before: "doctorReport.medicationEffect.reasonBefore", + insufficient_after: "doctorReport.medicationEffect.reasonAfter", + fit_failed: "doctorReport.medicationEffect.reasonFit", +} as const; + +const CONFOUNDER_KEYS = { + started: "medications.efficacy.segmented.confounderStarted", + stopped: "medications.efficacy.segmented.confounderStopped", + dose_change: "medications.efficacy.segmented.confounderDoseChange", + paused: "medications.efficacy.segmented.confounderPaused", + resumed: "medications.efficacy.segmented.confounderResumed", +} as const; + +export function WorkoutsSection({ + t, + report, + scope, + fmtDigits, +}: { + t: Translate; + report: DoctorReportData; + scope: LeafScope; + fmtDigits: (n: number, digits: number) => string; +}) { + const w = report.workouts ?? null; + return ( + + {w ? ( + <> +

+ {t("doctorReport.workouts.summary", { + sessions: w.sessions, + hours: fmtDigits(w.totalDurationSec / 3600, 1), + perWeek: fmtDigits(w.sessionsPerWeek, 1), + minutesPerWeek: fmtDigits(w.minutesPerWeek, 0), + })} +

+ {w.bySport.map((s) => ( + + ))} + {w.sportCount > w.bySport.length ? ( +

+ {t("doctorReport.workouts.moreSports", { + count: w.sportCount - w.bySport.length, + })} +

+ ) : null} + + ) : null} +
+ ); +} + +export function MedicationEffectSection({ + t, + report, + scope, + fmtDigits, + fmtDay, +}: { + t: Translate; + report: DoctorReportData; + scope: LeafScope; + fmtDigits: (n: number, digits: number) => string; + /** A stated calendar day (YYYY-MM-DD) in the owner's date order. */ + fmtDay: (day: string) => string; +}) { + // The block names medications: without the list leaf it shows nothing, + // whatever the payload carries. + const effects = scope.admits("MEDICATION_LIST") + ? (report.medicationEffects ?? []) + : []; + const glucoseUnit = resolveGlucoseUnit(report.glucoseUnit ?? null); + return ( + + {effects.flatMap((med) => + med.targets.map((target) => { + const e = target.effect; + const label = `${med.medicationName}: ${t( + reportMeasurementLabelKey(target.type as MeasurementType), + )}`; + const unit = reportStatUnit(target.type, glucoseUnit, t); + if (!e.present || !e.levelChange || !e.slopeChangePerWeek) { + return ( + + ); + } + const level = formatEstimate(e.levelChange, fmtDigits); + const slope = formatEstimate(e.slopeChangePerWeek, fmtDigits); + return ( +
+

{label}

+

+ {t("medications.efficacy.segmented.level", { + ...level, + unit, + })} +

+

+ {t("medications.efficacy.segmented.slope", { + ...slope, + unit, + })} +

+ {e.window ? ( +

+ {t("doctorReport.medicationEffect.windowLine", { + from: fmtDay(e.window.from), + to: fmtDay(e.window.to), + })} +

+ ) : null} +

+ {t("doctorReport.medicationEffect.days", { + before: e.days.before, + after: e.days.after, + excluded: + e.days.excludedLowAdherence + e.days.excludedNotTaking, + })} +

+ {e.fit?.autocorrelationCaveat ? ( +

+ {t("medications.efficacy.segmented.autocorrelation")} +

+ ) : null} + {e.confounders.map((c) => ( +

+ {t(CONFOUNDER_KEYS[c.event], { + name: c.medicationName, + date: fmtDay(c.date), + })} +

+ ))} +
+ ); + }), + )} + {effects.length > 0 ? ( +

+ {t("doctorReport.medicationEffect.method")} +

+ ) : null} +
+ ); +} diff --git a/src/components/clinician/clinician-view.tsx b/src/components/clinician/clinician-view.tsx index d7ab42b1f..2be46e88b 100644 --- a/src/components/clinician/clinician-view.tsx +++ b/src/components/clinician/clinician-view.tsx @@ -52,6 +52,10 @@ import { VisitsSection, } from "./history-sections"; import { DoseLogSection, Glp1Section } from "./therapy-sections"; +import { + MedicationEffectSection, + WorkoutsSection, +} from "./activity-effect-sections"; import { CycleSection, FamilyHistorySection, @@ -219,6 +223,13 @@ export function ClinicianView({ ) : null} + + {/* ── glucose and labs ─────────────────────────────────── */} + fmt.dateShortSmartCalendar(day)} + /> )} - {/* Meta row — the hero's quiet tier (UI-STANDARDS §text: `text-xs + {/* Meta row — the hero's quiet tier (ui-standards.md §3: `text-xs text-muted-foreground` is the meta floor; never an accent, never an opacity modifier). Freshness note (plan §2.4) — provisional day, last night's sleep not yet folded in. Muted, non-blocking, diff --git a/src/components/day/day-sections.tsx b/src/components/day/day-sections.tsx index d41c63600..498d06e2d 100644 --- a/src/components/day/day-sections.tsx +++ b/src/components/day/day-sections.tsx @@ -73,7 +73,7 @@ import { useDayValueFormat } from "./use-day-value-format"; * to look from, and the entry is where it is edited. */ -/** Section label, UI-STANDARDS §5. */ +/** Section label, ui-standards.md §5. */ export const DAY_SECTION_LABEL = "text-muted-foreground text-xs font-medium tracking-wide uppercase"; diff --git a/src/components/insights/__tests__/hero-strip.test.tsx b/src/components/insights/__tests__/hero-strip.test.tsx index 03a3ac366..c07126ce1 100644 --- a/src/components/insights/__tests__/hero-strip.test.tsx +++ b/src/components/insights/__tests__/hero-strip.test.tsx @@ -199,7 +199,7 @@ describe("", () => { /data-slot="insights-hero-strip-greeting"[^>]*class="([^"]*)"/, ); expect(greeting).not.toBeNull(); - // App H1 weight is bold (UI-STANDARDS §5) — never font-semibold. + // App H1 weight is bold (ui-standards.md §5) — never font-semibold. expect(greeting![1]).toContain("font-bold"); expect(greeting![1]).not.toContain("font-semibold"); // No off-scale arbitrary type sizes (§5) — round to the neighbouring step. 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 74207d86a..843949291 100644 --- a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx +++ b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx @@ -65,7 +65,7 @@ describe("", () => { expect(html).toContain("Stage composition"); expect(html).toContain("Last 23 nights"); // The title routes through the canonical tile header rather than a - // hand-rolled title row (UI-STANDARDS §1 / §8). + // hand-rolled title row (ui-standards.md §1 / §8). expect(html).toContain('data-slot="tile-header"'); }); diff --git a/src/components/insights/coach-panel/__tests__/chat-bubble-unverified-notice.test.tsx b/src/components/insights/coach-panel/__tests__/chat-bubble-unverified-notice.test.tsx index f695129aa..cb197eb54 100644 --- a/src/components/insights/coach-panel/__tests__/chat-bubble-unverified-notice.test.tsx +++ b/src/components/insights/coach-panel/__tests__/chat-bubble-unverified-notice.test.tsx @@ -76,7 +76,7 @@ describe("Coach unverified-figures notice", () => { />, ); expect(html).toContain('data-slot="coach-unverified-notice"'); - // Muted meta treatment (UI-STANDARDS §3) — never a warning colour. + // Muted meta treatment (ui-standards.md §3) — never a warning colour. expect(html).toContain("text-muted-foreground"); }); diff --git a/src/components/insights/coach-panel/__tests__/coach-settings-overlay.test.tsx b/src/components/insights/coach-panel/__tests__/coach-settings-overlay.test.tsx index 726f4b5d3..5b5680c33 100644 --- a/src/components/insights/coach-panel/__tests__/coach-settings-overlay.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-settings-overlay.test.tsx @@ -79,6 +79,8 @@ describe("", () => { expect(select).toContain('value="local"'); expect(select).toContain('value="admin-openai"'); expect(select).not.toContain('value="codex"'); + // The same preference Settings > AI writes; the link there stays. + expect(html).toContain('href="/settings/ai#ai-provider"'); }); it("shows the operator's central Codex as the active provider when it answers", () => { diff --git a/src/components/insights/coach-panel/chat-bubble.tsx b/src/components/insights/coach-panel/chat-bubble.tsx index 81c6345a3..00ed2e220 100644 --- a/src/components/insights/coach-panel/chat-bubble.tsx +++ b/src/components/insights/coach-panel/chat-bubble.tsx @@ -520,7 +520,7 @@ function ChatBubbleImpl({ // #781 — the persisted cancelled-turn marker (an EMPTY assistant row the // server writes when the client aborts mid-generation, e.g. navigating // away). Rendered as a quiet interrupted note — muted meta text per - // UI-STANDARDS §3, never an alarm colour: nothing failed, the user left — + // ui-standards.md §3, never an alarm colour: nothing failed, the user left — // plus a retry that resubmits the question that produced the turn (the // regenerate closure, so the resend runs through the normal send path and // every budget/rate gate). No provenance, feedback, copy, or token footer: @@ -700,7 +700,7 @@ function ChatBubbleImpl({

{safeError}

)} {/* v1.32.14 — quiet grounding caveat when the guard withheld a figure. - Muted meta (UI-STANDARDS §3), never a warning colour — it is a + Muted meta (ui-standards.md §3), never a warning colour — it is a grounding caveat, not an error. Static single line, decorative icon, no interactivity. The `[…]` marks in the prose above point here. */} {showUnverifiedNotice && ( diff --git a/src/components/insights/coach-panel/coach-model-picker.tsx b/src/components/insights/coach-panel/coach-model-picker.tsx index 7ff77cef3..a6ba1bb1c 100644 --- a/src/components/insights/coach-panel/coach-model-picker.tsx +++ b/src/components/insights/coach-panel/coach-model-picker.tsx @@ -1,10 +1,12 @@ "use client"; import { useState } from "react"; -import { Loader2 } from "lucide-react"; +import Link from "next/link"; +import { ChevronRight, Loader2 } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; +import { InlineAction } from "@/components/ui/inline-action"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; @@ -44,6 +46,10 @@ import { QueryErrorRow } from "@/components/ui/query-error-row"; * operator's shared provider and Codex show their state read-only. * Every write invalidates the AI input keys, so the capability on * `/api/auth/me` comes back from the server rather than being guessed here. + * + * v1.44 briefly replaced the selects with a read-only line; switching the + * model mid-conversation is how people use it, so the selects are back, with + * the link to Settings > AI kept below them for everything else. */ /** The configured chain with `provider` first; everything else keeps order. */ @@ -329,6 +335,14 @@ export function CoachModelPicker() { {error}

) : null} +

+ + + {t("insights.coach.frame.changeModel")} + +

); } diff --git a/src/components/insights/ecg-detail.tsx b/src/components/insights/ecg-detail.tsx index ff840a099..694c280fa 100644 --- a/src/components/insights/ecg-detail.tsx +++ b/src/components/insights/ecg-detail.tsx @@ -52,7 +52,7 @@ export function EcgDetail({ recordingId }: { recordingId: string }) { }); if (isError) { - // A failed read must never read as "no data" (UI-STANDARDS §6), and the + // A failed read must never read as "no data" (ui-standards.md §6), and the // alert is content, not muted meta — it also has to be recoverable. return ( diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index 22129a95e..a59a2baa3 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -1,5 +1,6 @@ "use client"; +import { SourceProvenanceLink } from "@/components/provenance/source-provenance-link"; import Link from "next/link"; import type { ComponentProps, ComponentType, ReactNode } from "react"; @@ -523,6 +524,9 @@ export function HealthKitMetricPage({ {targetSummarySlug ? ( ) : null} + {/* v1.44 (#1204) — where the values come from, for a metric the + source priority decides for. Nothing for any other metric. */} + {/* v1.17.0 — page-specific extra block (blood glucose: the clinical panel). Mounted on the data-bearing branch only. */} {afterChart} diff --git a/src/components/insights/hero-strip.tsx b/src/components/insights/hero-strip.tsx index 974f2b535..1ec661886 100644 --- a/src/components/insights/hero-strip.tsx +++ b/src/components/insights/hero-strip.tsx @@ -164,7 +164,7 @@ export function HeroStrip({ >
- {/* Header glyph stays in the foreground tier — UI-STANDARDS §1 + {/* Header glyph stays in the foreground tier — ui-standards.md §1 reserves primary/accent for actions, not decorative header icons. */} {/* The delta is CONTENT — the number the row exists to report — so it takes the analyte's tier, not the meta tier of the - line under it (UI-STANDARDS §3). At `text-xs` muted it wore + line under it (ui-standards.md §3). At `text-xs` muted it wore the sub-line's typography and optically attached itself to that lower line, which is what made a row centred to the pixel read as bottom-aligned. `self-center` states the row's diff --git a/src/components/insights/medication-efficacy-summary.tsx b/src/components/insights/medication-efficacy-summary.tsx index 3b1d52ff9..773bbccc7 100644 --- a/src/components/insights/medication-efficacy-summary.tsx +++ b/src/components/insights/medication-efficacy-summary.tsx @@ -8,7 +8,7 @@ * strictly association-framed, never a verdict. Each row links through to the * medication's Wirkung tab for the full view. * - * Card anatomy per UI-STANDARDS §8: TileHeader + the inherited `px-4 md:px-6` + * Card anatomy per ui-standards.md §8: TileHeader + the inherited `px-4 md:px-6` * inset (never re-declared), foreground for content, muted for meta. */ import Link from "next/link"; diff --git a/src/components/insights/mood/mood-discovered-relations.tsx b/src/components/insights/mood/mood-discovered-relations.tsx index 6f72d7148..1902f881d 100644 --- a/src/components/insights/mood/mood-discovered-relations.tsx +++ b/src/components/insights/mood/mood-discovered-relations.tsx @@ -80,8 +80,28 @@ const CHANNEL_LABEL_KEY: Record = { // v1.43 — the Dawarich movement channels. MOVEMENT_AWAY: "environment.movement.awayFromHome", MOVEMENT_DISTANCE: "environment.movement.distance", + // v1.44 — the extended channel families. + HRV_RMSSD: "measurements.typeHrvRmssd", + EXERCISE_MINUTES: "measurements.typeExerciseMinutes", + STAND_MINUTES: "measurements.typeStandMinutes", + MINDFUL_MINUTES: "measurements.typeMindfulMinutes", + HYDRATION: "nutrients.names.water", + CAFFEINE: "nutrients.names.caffeine", + ALCOHOL: "insights.mood.discovery.channel.ALCOHOL", + WORKOUT_MINUTES: "insights.mood.discovery.channel.WORKOUT_MINUTES", + TRAINING_LOAD: "insights.mood.discovery.channel.TRAINING_LOAD", }; +/** A micronutrient channel (`NUTRIENT:`) reads under its catalog name. */ +const NUTRIENT_PREFIX = "NUTRIENT:"; + +function channelLabelKey(channelKey: string): string { + if (channelKey.startsWith(NUTRIENT_PREFIX)) { + return `nutrients.names.${channelKey.slice(NUTRIENT_PREFIX.length)}`; + } + return CHANNEL_LABEL_KEY[channelKey] ?? channelKey; +} + /** Keep only the discovered pairs that involve the mood channel. */ export function moodPairsOf( discovered: DiscoveredCorrelation[], @@ -131,8 +151,7 @@ export function MoodDiscoveredRelations({ const dynamicLabel = moodIsOutcome ? pair.behaviourLabel : pair.outcomeLabel; - const factorLabel = - dynamicLabel ?? t(CHANNEL_LABEL_KEY[channelKey] ?? channelKey); + const factorLabel = dynamicLabel ?? t(channelLabelKey(channelKey)); if ( patternId && dismissal.isDismissed(patternId, pair.dismissed === true) diff --git a/src/components/insights/nutrients/hydration-card.tsx b/src/components/insights/nutrients/hydration-card.tsx index 5d61aa219..1f62f9fc2 100644 --- a/src/components/insights/nutrients/hydration-card.tsx +++ b/src/components/insights/nutrients/hydration-card.tsx @@ -22,7 +22,7 @@ const WINDOW_DAYS = 30; * reference-intake meta line (omitted when the profile has no sex on * file — the catalog's own contract), a 30-day bar chart with a dashed * reference line. No ring, no attainment colour — the bar chart with a - * reference line IS the context (UI-STANDARDS: value stays foreground, + * reference line IS the context (ui-standards.md: value stays foreground, * never coloured by attainment). Display only: water arrives by sync * (Apple Health etc.), there is no in-app water entry here. */ diff --git a/src/components/insights/use-insights-advisor.ts b/src/components/insights/use-insights-advisor.ts index e19700589..9633a652e 100644 --- a/src/components/insights/use-insights-advisor.ts +++ b/src/components/insights/use-insights-advisor.ts @@ -26,7 +26,7 @@ import type { BriefingFailureClass } from "@/lib/insights/briefing-failure-marke * * Why a POST under a `useQuery` rather than a dedicated GET endpoint: * the route already cache-returns the most recent generation (24h TTL on - * `User.insightsCachedAt` / `User.insightsCachedText`) without burning a + * `User.insightsCachedAt` / `User.insightsCachedTextEncrypted`) without burning a * rate-limit token, so a single POST-without-`force` is functionally a * GET-or-generate. Adding a separate GET would duplicate the cache-read * branch and split the audit-log surface; the reconcile report deferred @@ -540,7 +540,7 @@ export function useInsightsAdvisorQuery( // re-fetches. queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); // v1.18.10 / v1.32.19 — the dashboard hero surfaces the briefing - // headline from the SNAPSHOT (server-lifted `User.insightsCachedText`), + // headline from the SNAPSHOT (server-lifted `User.insightsCachedTextEncrypted`), // and the Today hero's lead line is the SAME briefing lifted into the // digest — neither is this advisor cache. A regenerate rewrites that // server cache, so both the snapshot and the digest must refetch. diff --git a/src/components/labs/__tests__/lab-trend-card.test.tsx b/src/components/labs/__tests__/lab-trend-card.test.tsx new file mode 100644 index 000000000..0a30bd0e2 --- /dev/null +++ b/src/components/labs/__tests__/lab-trend-card.test.tsx @@ -0,0 +1,125 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { primeLocales } from "@/__tests__/helpers/prime-locales"; + +import { LabTrendCard } from "../lab-trend-card"; +import type { BiomarkerTrendDto } from "../types"; + +primeLocales("de"); + +const OK: BiomarkerTrendDto = { + status: "ok", + n: 4, + firstAt: "2023-01-10", + lastAt: "2026-01-10", + spanDays: 1096, + slopePerYear: 12.5, + slopeCi95: [4.25, 20.75], + pValue: 0.02, + fittedNow: 130, + asOf: "2026-10-21", + horizonYears: 3, + horizonCapped: true, + projection: "exits", + exitDate: "2027-11-02", + exitBound: "high", +}; + +function render( + trend: BiomarkerTrendDto | undefined, + locale: "en" | "de" = "en", +) { + return renderToStaticMarkup( + + + , + ); +} + +describe("", () => { + it("words the server's slope and labels the exit as a projection", () => { + const html = render(OK); + expect(html).toContain("Rising by about 13 mg/dL a year across 4 readings"); + expect(html).toContain('data-projection="exits"'); + expect(html).toContain("upper end of your reference range"); + expect(html).toContain("projection, not a forecast"); + expect(html).toContain("4.25 to 20.75 mg/dL"); + }); + + it("speaks German with du", () => { + const html = render(OK, "de"); + expect(html).toContain("deines Referenzbereichs"); + expect(html).toContain("Fortschreibung, keine Vorhersage"); + }); + + it("draws no projection sentence without a clear trend, and nothing when insufficient", () => { + const html = render({ + ...OK, + projection: "no_clear_trend", + exitDate: null, + exitBound: null, + }); + expect(html).toContain('data-projection="no_clear_trend"'); + expect(html).not.toContain("forecast"); + expect( + render({ + status: "insufficient", + reason: "too_few_values", + n: 2, + spanDays: 40, + }), + ).toBe(""); + expect(render(undefined)).toBe(""); + }); + + it("words a stale trend as of its newest reading, and a span-bound horizon", () => { + const stale = render({ + ...OK, + asOf: "2026-01-10", + projection: "stale", + exitDate: null, + exitBound: null, + }); + expect(stale).toContain('data-projection="stale"'); + expect(stale).toContain("more than a year ago"); + expect(stale).not.toContain("forecast"); + const beyond = render({ + ...OK, + horizonYears: 1.5, + horizonCapped: false, + projection: "beyond_horizon", + exitDate: null, + exitBound: null, + }); + expect(beyond).toContain("for more than 1.5 years"); + expect(beyond).toContain("the span your readings cover"); + }); + + it("does not call a capped three-year horizon the span of the readings", () => { + for (const locale of ["en", "de"] as const) { + const capped = render( + { + ...OK, + horizonYears: 3, + horizonCapped: true, + projection: "beyond_horizon", + exitDate: null, + exitBound: null, + }, + locale, + ); + expect(capped).toContain( + locale === "en" ? "more than 3 years" : "länger als 3 Jahre", + ); + expect(capped).not.toContain("the span your readings cover"); + expect(capped).not.toContain("so weit reichen deine Werte zurück"); + expect(capped).toContain( + locale === "en" + ? "as far ahead as the projection looks" + : "so weit schaut die Fortschreibung voraus", + ); + } + }); +}); diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index f83352055..67978895b 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -55,6 +55,7 @@ import { importWithRetry } from "@/lib/retry-import"; import { BiomarkerAliases } from "./biomarker-aliases"; import { BiomarkerForm } from "./biomarker-form"; import { LabForm } from "./lab-form"; +import { LabTrendCard } from "./lab-trend-card"; import type { BiomarkerDto, LabResultDto, @@ -505,6 +506,10 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { /> + )} diff --git a/src/components/labs/lab-trend-card.tsx b/src/components/labs/lab-trend-card.tsx new file mode 100644 index 000000000..94f87a107 --- /dev/null +++ b/src/components/labs/lab-trend-card.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { TrendingUp } from "lucide-react"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; + +import type { BiomarkerTrendDto } from "./types"; + +/** + * v1.44 — the marker's slope over all its readings, and where that straight + * line would leave the reference band. + * + * Server-authoritative: every number is the route's (`computeLabTrend`); this + * only words it. Renders nothing below three readings over a year. The exit + * sentence names itself a projection, never a forecast. + */ +export function LabTrendCard({ + trend, + unit, +}: { + trend: BiomarkerTrendDto | undefined; + unit: string; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + if (!trend || trend.status !== "ok") return null; + + const digits = Math.abs(trend.slopePerYear) >= 10 ? 0 : 2; + const per = (v: number) => fmt.number(Math.abs(v), digits); + const direction = + trend.slopePerYear > 0 + ? "rising" + : trend.slopePerYear < 0 + ? "falling" + : "flat"; + const slopeLine = t(`labs.trend.slope.${direction}`, { + amount: per(trend.slopePerYear), + unit, + n: trend.n, + since: fmt.dateShortSmartCalendar(trend.firstAt), + }); + const projectionLine = + trend.projection === "exits" && trend.exitDate + ? t(`labs.trend.projection.exits.${trend.exitBound ?? "high"}`, { + date: fmt.dateShortSmartCalendar(trend.exitDate), + }) + : trend.projection === "no_band" + ? null + : trend.projection === "stale" + ? t("labs.trend.projection.stale", { + date: fmt.dateShortSmartCalendar(trend.lastAt), + }) + : trend.projection === "beyond_horizon" + ? t( + trend.horizonCapped + ? "labs.trend.projection.beyond_horizon_capped" + : "labs.trend.projection.beyond_horizon", + { + years: fmt.number( + trend.horizonYears, + trend.horizonCapped ? 0 : 1, + ), + }, + ) + : t(`labs.trend.projection.${trend.projection}`); + + return ( + + + + + +

+ {slopeLine} +

+ {projectionLine ? ( +

+ {projectionLine} +

+ ) : null} +

+ {t("labs.trend.interval", { + lo: fmt.number(trend.slopeCi95[0], 2), + hi: fmt.number(trend.slopeCi95[1], 2), + unit, + })} +

+
+
+ ); +} diff --git a/src/components/labs/labs-settings.tsx b/src/components/labs/labs-settings.tsx index 8988fd20b..12ca02e37 100644 --- a/src/components/labs/labs-settings.tsx +++ b/src/components/labs/labs-settings.tsx @@ -1,17 +1,16 @@ "use client"; /** - * v1.18.6 — the Labs module's own settings page body. + * The Labs module's settings, in two places since v1.44: * - * Reached from the wrench beside the page's "hinzufügen" button. Lets the - * user: - * - pick the card-vs-list page view (MOD-03), - * - choose the sort order (by most-recent reading, descending / ascending, - * or manual) — MOD-04, - * - reorder biomarkers when sort = manual (MOD-03/04), - * - and create / edit / delete biomarkers via the existing - * `BiomarkerManager` (MOD-04). - * View / sort / order persist client-side via `useModuleListPrefs("labs")`. + * `LabsSettings` — Settings > Arrangement > Labs: the card-vs-list view + * (MOD-03), the sort (recency, alphabetical or manual, MOD-04) and the manual + * order of the biomarkers. Order and view only, persisted per account through + * `useModuleListPrefs("labs")`. + * + * `LabsModuleSettings` — Settings > Modules > Labs: the on-device text + * recognition opt-in and the biomarker catalog (`BiomarkerManager`). Reached + * from the wrench on the labs page. */ import { useQuery } from "@tanstack/react-query"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; @@ -52,12 +51,6 @@ export function LabsSettings() { const { t } = useTranslations(); const { prefs, setView, setOrder, setSortDir } = useModuleListPrefs("labs"); - // v1.18.10 — local (in-browser) OCR opt-in. Lets a user whose AI provider - // can't read images still scan a report: the image is OCR'd on-device and - // only the extracted text is sent to the text-only provider. - const localOcr = useLabsLocalOcr(); - const updateLocalOcr = useUpdateLabsLocalOcr(); - const { data, isLoading } = useQuery({ queryKey: queryKeys.biomarkers(), queryFn: () => apiGet("/api/biomarkers"), @@ -130,7 +123,20 @@ export function LabsSettings() { )} ) : null} +
+ ); +} +export function LabsModuleSettings() { + const { t } = useTranslations(); + // v1.18.10 — local (in-browser) OCR opt-in. Lets a user whose AI provider + // can't read images still scan a report: the image is OCR'd on-device and + // only the extracted text is sent to the text-only provider. + const localOcr = useLabsLocalOcr(); + const updateLocalOcr = useUpdateLabsLocalOcr(); + + return ( +
{/* v1.18.10 — local (in-browser) OCR opt-in for lab scans. */} ` (UI-STANDARDS §9 one-scroll-floor). + // surface beside `
` (ui-standards.md §9 one-scroll-floor). // The class is not hypothetical and not confined to app code. A // Radix `` outside a `
` renders a hidden // `position:absolute` bubble input on the first pass and drops it diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts index 03c27e845..10f5cd7ff 100644 --- a/src/components/layout/nav-model.ts +++ b/src/components/layout/nav-model.ts @@ -274,7 +274,7 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ * * Settings and Notifications are account utilities, not feature destinations: * on desktop they live in the sidebar footer + avatar menu, on mobile in the - * top-bar user menu (UI-STANDARDS §10; the More hub carries features only). + * top-bar user menu (ui-standards.md §10; the More hub carries features only). * They used to be a second hand-curated list on each bar — the exact drift the * one-model contract above set out to kill, just pushed down a level. This * list is the single source both bars consume, so the two surfaces can no diff --git a/src/components/layout/shared-record-banner.tsx b/src/components/layout/shared-record-banner.tsx index de3764e52..225490f04 100644 --- a/src/components/layout/shared-record-banner.tsx +++ b/src/components/layout/shared-record-banner.tsx @@ -33,7 +33,7 @@ import { accountLabel } from "@/lib/sharing/account-access-view"; * dark for the muted token at this text size, while the foreground token keeps * the context legible at the same contrast floor as the name. * - * The row is the inline-action shape from UI-STANDARDS §11 rather than a + * The row is the inline-action shape from ui-standards.md §11 rather than a * centred wrapping stack: sentence `min-w-0 flex-1`, action `shrink-0`, so the * way out stays beside the sentence instead of dropping onto its own line once * the name is long. `` is the sibling that already had an diff --git a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx index 72d5004fa..cd520529c 100644 --- a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx +++ b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx @@ -114,6 +114,16 @@ describe(" loading + empty", () => { expect(html).not.toContain('data-slot="vorsorge-loading"'); }); + it("links the checkups page to the reminder and screening settings that moved", () => { + remindersMock.mockReturnValue({ data: [], isLoading: false }); + const page = render(); + expect(page).toContain('href="/settings/notifications#vorsorge-reminders"'); + expect(page).toContain('href="/settings/anamnesis#screening-hints"'); + // The embedded settings card is already in Settings and carries no links. + const embedded = render(); + expect(embedded).not.toContain('data-slot="vorsorge-settings-links"'); + }); + it("renders reminder cards once data lands", () => { const reminder: MeasurementReminder = { id: "r1", diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index d813ebbcd..654cc6f9d 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -465,7 +465,38 @@ export function VorsorgeSection({ } /> - ) : ( + ) : null} + {variant === "page" && !inSharedRecord ? ( + // The reminder switches and the screening hints moved out of the + // checkups settings in v1.44 (Notifications, Health profile); both + // stay one tap away from the page they are about. +
+ + +
+ ) : null} + {variant === "page" ? null : ( Arrangement > Checkups: the card-vs-list + * view and the manual order, persisted per account through + * `useModuleListPrefs("vorsorge")`. + * + * `VorsorgeReminderSwitchesCard` — the per-reminder on/off switches, under + * Settings > Notifications with the other reminders, persisted through the * reminder PATCH route. */ import { ArrowUpDown, LayoutGrid, ListChecks } from "lucide-react"; @@ -47,7 +48,6 @@ function resolveLabel( export function VorsorgeSettings() { const { t } = useTranslations(); const { data: reminders, isLoading } = useMeasurementReminders(true); - const { update } = useMeasurementReminderMutations(); const { prefs, setView, setOrder } = useModuleListPrefs("vorsorge"); const ordered = applyOrder(reminders ?? [], prefs.order, (r) => r.id); @@ -59,7 +59,7 @@ export function VorsorgeSettings() { : t("measurementReminders.selfPlanned"), })); - // v1.18.10 — the three Vorsorge settings groups adopt the shared + // v1.18.10 — the Vorsorge settings groups adopt the shared // `SettingsCard` + `SettingsCardHeader` contract (rounded-xl bordered // surface, neutral muted icon, `text-lg` title, short muted description) // so this surface reads like every other Settings section instead of bare @@ -96,67 +96,80 @@ export function VorsorgeSettings() { )}
- - - -
- {isLoading ? ( - - ) : (reminders?.length ?? 0) === 0 ? ( - - ) : ( - - - {ordered.map((reminder) => ( -
- - {resolveLabel(reminder, t)} - - - update.mutate( - { id: reminder.id, body: { enabled: next } }, - { - onSuccess: () => - toastWrittenOutcome( - "success", - t("settings.saved"), - ), - onError: (failure) => - showErrorToast(t("settings.savingError"), { - cause: failure, - }), - }, - ) - } - disabled={update.isPending} - // Label each row's Switch by its own name span so SR - // users hear the reminder name (plus the switch role's - // on/off state) instead of N identical generic labels. - aria-labelledby={`vorsorge-reminder-label-${reminder.id}`} - /> -
- ))} -
-
- )} -
-
); } + +/** + * The on/off switch of each preventive-care reminder. A reminder, so since + * v1.44 it lives with the other reminders under Settings > Notifications + * (`#vorsorge-reminders`, the wrapper there); the checkups page links + * there. + */ +export function VorsorgeReminderSwitchesCard() { + const { t } = useTranslations(); + const { data: reminders, isLoading } = useMeasurementReminders(true); + const { update } = useMeasurementReminderMutations(); + const { prefs } = useModuleListPrefs("vorsorge"); + const ordered = applyOrder(reminders ?? [], prefs.order, (r) => r.id); + + return ( + + +
+ {isLoading ? ( + + ) : (reminders?.length ?? 0) === 0 ? ( + + ) : ( + + + {ordered.map((reminder) => ( +
+ + {resolveLabel(reminder, t)} + + + update.mutate( + { id: reminder.id, body: { enabled: next } }, + { + onSuccess: () => + toastWrittenOutcome("success", t("settings.saved")), + onError: (failure) => + showErrorToast(t("settings.savingError"), { + cause: failure, + }), + }, + ) + } + disabled={update.isPending} + // Label each row's Switch by its own name span so SR + // users hear the reminder name (plus the switch role's + // on/off state) instead of N identical generic labels. + aria-labelledby={`vorsorge-reminder-label-${reminder.id}`} + /> +
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index e190c189a..e0d6a8125 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -132,6 +132,16 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { PAIN_NRS: "measurements.typePainNrs", WAIST_CIRCUMFERENCE: "measurements.typeWaistCircumference", WAIST_TO_HEIGHT: "measurements.typeWaistToHeight", + // ── v1.44 — activity rings, mindfulness, State of Mind, AFib burden ── + EXERCISE_MINUTES: "measurements.typeExerciseMinutes", + STAND_MINUTES: "measurements.typeStandMinutes", + STAND_HOURS: "measurements.typeStandHours", + MINDFUL_MINUTES: "measurements.typeMindfulMinutes", + STATE_OF_MIND_VALENCE: "measurements.typeStateOfMindValence", + AFIB_BURDEN: "measurements.typeAfibBurden", + INDOOR_CO2: "measurements.typeIndoorCo2", + INDOOR_TEMPERATURE: "measurements.typeIndoorTemperature", + INDOOR_HUMIDITY: "measurements.typeIndoorHumidity", }; export const MEASUREMENT_TYPE_ICONS: Record = { @@ -267,6 +277,18 @@ export const MEASUREMENT_TYPE_ICONS: Record = { PAIN_NRS: Activity, WAIST_CIRCUMFERENCE: Scale, WAIST_TO_HEIGHT: Scale, + // ── v1.44 — the rings take the activity glyphs, mindful minutes the + // breath, the valence the score dial, AFib burden the rhythm trace. + EXERCISE_MINUTES: Dumbbell, + STAND_MINUTES: PersonStanding, + STAND_HOURS: PersonStanding, + MINDFUL_MINUTES: Wind, + STATE_OF_MIND_VALENCE: Gauge, + AFIB_BURDEN: HeartPulse, + // Room climate: air, warmth, moisture. + INDOOR_CO2: Wind, + INDOOR_TEMPERATURE: Thermometer, + INDOOR_HUMIDITY: Droplets, }; // The colour map lives in its own icon-free module so a surface that only diff --git a/src/components/measurements/measurement-type-colors.ts b/src/components/measurements/measurement-type-colors.ts index 5f3386b6c..98409b66f 100644 --- a/src/components/measurements/measurement-type-colors.ts +++ b/src/components/measurements/measurement-type-colors.ts @@ -129,4 +129,17 @@ export const MEASUREMENT_TYPE_COLORS: Record = { PAIN_NRS: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", WAIST_CIRCUMFERENCE: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20", WAIST_TO_HEIGHT: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20", + // ── v1.44 — the rings and mindful minutes join the activity family + // (chart-2), the valence the score family (chart-1), AFib burden the + // cardio family (chart-3). + EXERCISE_MINUTES: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + STAND_MINUTES: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + STAND_HOURS: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + MINDFUL_MINUTES: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + STATE_OF_MIND_VALENCE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", + AFIB_BURDEN: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", + // Room climate takes the environment family of daylight (chart-2). + INDOOR_CO2: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + INDOOR_TEMPERATURE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", + INDOOR_HUMIDITY: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", }; diff --git a/src/components/medications/detail/efficacy/__tests__/change-at-start-summary.test.tsx b/src/components/medications/detail/efficacy/__tests__/change-at-start-summary.test.tsx new file mode 100644 index 000000000..6f40afc8a --- /dev/null +++ b/src/components/medications/detail/efficacy/__tests__/change-at-start-summary.test.tsx @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import enMessages from "../../../../../../messages/en.json"; +import type { EfficacySegmented } from "@/lib/medications/efficacy/segmented-effect"; +import { ChangeAtStartSummary } from "../change-at-start-summary"; + +function est(estimate: number, se: number) { + const ciLow = estimate - 2 * se; + const ciHigh = estimate + 2 * se; + return { + estimate, + ciLow, + ciHigh, + standardError: se, + includesZero: ciLow <= 0 && ciHigh >= 0, + }; +} + +const present: EfficacySegmented = { + present: true, + level: 0.95, + window: { from: "2026-01-01", to: "2026-05-30", endReason: "dose_change" }, + days: { + before: 80, + after: 70, + excludedLowAdherence: 6, + excludedNotTaking: 0, + }, + adherence: { status: "tracked", floorPct: 80, lookbackDays: 14 }, + levelChange: est(-7.34, 0.6), + slopeChangePerWeek: est(-0.26, 0.16), + preSlopePerWeek: est(0.05, 0.1), + fit: { + n: 150, + df: 146, + rSquared: 0.6, + residualAutocorrelation: 0.3, + hacLag: 7, + autocorrelationCaveat: false, + }, + confounders: [ + { medicationName: "Example B", event: "started", date: "2026-02-10" }, + ], +}; + +function render(segmented: EfficacySegmented) { + return renderToStaticMarkup( + + + , + ); +} + +describe("ChangeAtStartSummary", () => { + it("states the level and trend change with their ranges in plain words", () => { + const html = render(present); + expect(html).toContain("the level moved by -7.3 mmHg"); + expect(html).toContain("95 % range -8.5 to -6.1"); + expect(html).toContain("changed by -0.3 mmHg per week"); + // The slope interval spans zero, so the no-change reading is said aloud. + expect(html).toContain("also consistent with no change"); + expect(html).toContain("Based on 80 days before and 70 days after"); + expect(html).toContain("6 days after the start were left out"); + expect(html).toContain("describes the starting dose"); + expect(html).toContain("Example B started on"); + expect(html).not.toMatch(/work(s|ing)|effective|successful/i); + expect(html).not.toContain("medication-wirkung-autocorrelation"); + }); + + it("warns when the daily values carry over strongly", () => { + const html = render({ + ...present, + fit: { + ...present.fit!, + residualAutocorrelation: 0.7, + autocorrelationCaveat: true, + }, + }); + expect(html).toContain("medication-wirkung-autocorrelation"); + expect(html).toContain("even these ranges may be too narrow"); + }); + + it("names the missing floor instead of a number", () => { + const html = render({ + ...present, + present: false, + reason: "insufficient_before", + levelChange: null, + slopeChangePerWeek: null, + preSlopePerWeek: null, + fit: null, + }); + expect(html).toContain("at least 14 days spread over 21 days before"); + expect(html).not.toContain("level moved"); + }); +}); diff --git a/src/components/medications/detail/efficacy/change-at-start-summary.tsx b/src/components/medications/detail/efficacy/change-at-start-summary.tsx new file mode 100644 index 000000000..af2c132fb --- /dev/null +++ b/src/components/medications/detail/efficacy/change-at-start-summary.tsx @@ -0,0 +1,163 @@ +"use client"; + +/** + * The segmented-regression read-out on a Wirkung target card: the level + * change and the change in weekly trend at the start, each with its 95 % + * range, in plain sentences, followed by what the fit rests on (days used, + * days left out and why) and the other medication changes it cannot separate + * from this one. Renders the server DTO; recomputes nothing. + */ +import type { EfficacySegmented } from "@/lib/medications/efficacy/segmented-effect"; +import { formatEstimate } from "@/lib/medications/efficacy/segmented-format"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; + +const CONFOUNDER_KEYS = { + started: "medications.efficacy.segmented.confounderStarted", + stopped: "medications.efficacy.segmented.confounderStopped", + dose_change: "medications.efficacy.segmented.confounderDoseChange", + paused: "medications.efficacy.segmented.confounderPaused", + resumed: "medications.efficacy.segmented.confounderResumed", +} as const; + +const NEED_KEYS = { + no_start: "medications.efficacy.segmented.needStart", + insufficient_before: "medications.efficacy.segmented.needBefore", + insufficient_after: "medications.efficacy.segmented.needAfter", + fit_failed: "medications.efficacy.segmented.fitFailed", +} as const; + +export function ChangeAtStartSummary({ + segmented, + unit, + minDays, + minSpanDays, +}: { + segmented: EfficacySegmented; + unit: string | null; + minDays: number; + minSpanDays: number; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const num = (v: number, d: number) => fmt.number(v, d); + const u = unit ?? ""; + + if ( + !segmented.present || + !segmented.levelChange || + !segmented.slopeChangePerWeek || + !segmented.preSlopePerWeek || + !segmented.window + ) { + return ( +
+

+ {t(NEED_KEYS[segmented.reason ?? "fit_failed"], { + days: minDays, + span: minSpanDays, + })} +

+
+ ); + } + + const level = formatEstimate(segmented.levelChange, num); + const slope = formatEstimate(segmented.slopeChangePerWeek, num); + const pre = formatEstimate(segmented.preSlopePerWeek, num); + const anyZero = + segmented.levelChange.includesZero || + segmented.slopeChangePerWeek.includesZero; + + return ( +
+

+ {t("medications.efficacy.segmented.title")} +

+

+ {t("medications.efficacy.segmented.level", { ...level, unit: u })} +

+

+ {t("medications.efficacy.segmented.slope", { ...slope, unit: u })} +

+

+ {t("medications.efficacy.segmented.preSlope", { + value: pre.value, + unit: u, + })} +

+ {anyZero ? ( +

+ {t("medications.efficacy.segmented.includesZero")} +

+ ) : null} +

+ {t("medications.efficacy.segmented.basis", { + before: segmented.days.before, + after: segmented.days.after, + from: fmt.dateShortSmartCalendar(segmented.window.from), + to: fmt.dateShortSmartCalendar(segmented.window.to), + })} +

+ {segmented.days.excludedLowAdherence > 0 ? ( +

+ {t("medications.efficacy.segmented.excludedAdherence", { + count: segmented.days.excludedLowAdherence, + floor: segmented.adherence.floorPct, + lookback: segmented.adherence.lookbackDays, + })} +

+ ) : null} + {segmented.days.excludedNotTaking > 0 ? ( +

+ {t("medications.efficacy.segmented.excludedNotTaking", { + count: segmented.days.excludedNotTaking, + })} +

+ ) : null} + {segmented.adherence.status === "not_tracked" ? ( +

+ {t("medications.efficacy.segmented.notTracked")} +

+ ) : null} + {segmented.fit?.autocorrelationCaveat ? ( +

+ {t("medications.efficacy.segmented.autocorrelation")} +

+ ) : null} + {segmented.window.endReason === "dose_change" ? ( +

+ {t("medications.efficacy.segmented.endDoseChange")} +

+ ) : null} + {segmented.confounders.length > 0 ? ( +
+

+ {t("medications.efficacy.segmented.confoundersTitle")} +

+
    + {segmented.confounders.map((c) => ( +
  • + {t(CONFOUNDER_KEYS[c.event], { + name: c.medicationName, + date: fmt.dateShortSmartCalendar(c.date), + })} +
  • + ))} +
+
+ ) : null} +

+ {t("medications.efficacy.segmented.method")} +

+
+ ); +} diff --git a/src/components/medications/detail/efficacy/efficacy-tab.tsx b/src/components/medications/detail/efficacy/efficacy-tab.tsx index a4d1cdde4..fde9cbe77 100644 --- a/src/components/medications/detail/efficacy/efficacy-tab.tsx +++ b/src/components/medications/detail/efficacy/efficacy-tab.tsx @@ -43,6 +43,11 @@ import type { EfficacyTargetView, } from "@/lib/medications/efficacy/build-efficacy"; import { EmptyState } from "@/components/ui/empty-state"; +import { + SEGMENTED_MIN_DAYS_PER_SIDE, + SEGMENTED_MIN_SPAN_DAYS, +} from "@/lib/medications/efficacy/segmented-effect"; +import { ChangeAtStartSummary } from "./change-at-start-summary"; // v1.30 UI audit (M8) — `EfficacyChart` mounts inside `TargetCard`'s own // `` as a bare 220px `ResponsiveContainer` with no chrome of its own. @@ -310,6 +315,15 @@ function TargetCard({ + {target.segmented ? ( + + ) : null} + {target.levelShift?.present && target.levelShift.nearStart && target.levelShift.at ? ( diff --git a/src/components/medications/medication-order-editor.tsx b/src/components/medications/medication-order-editor.tsx index 3c26dcb28..8b50ce96f 100644 --- a/src/components/medications/medication-order-editor.tsx +++ b/src/components/medications/medication-order-editor.tsx @@ -151,7 +151,7 @@ export function MedicationOrderEditor({ // `relative` anchors the sr-only drag-hint paragraph: `sr-only` is // absolutely positioned, and without a positioned ancestor its 1px box // lands below the list in DOCUMENT coordinates and makes the document - // itself scrollable (second scrollbar — UI-STANDARDS §9 one-scroll-floor). + // itself scrollable (second scrollbar — ui-standards.md §9 one-scroll-floor).
{medications.length === 0 && ( + renderToStaticMarkup( + + + , + ); + +describe("", () => { + it("states each change and whether it reaches the threshold", () => { + const html = render(TREND); + expect(html).toContain("-3 points"); + expect(html).toContain("smaller than the 5-point change"); + expect(html).toContain("-7 points"); + expect(html).toContain('data-verdict="improved"'); + }); + + it("shows a rise with its sign, in German with du", () => { + const html = render( + { ...TREND, n: 2, changeFromPrevious: 6, sincePrevious: "worsened" }, + "de", + ); + expect(html).toContain("Seit deinem vorigen Check-in"); + expect(html).toContain("+6 Punkte"); + expect(html).not.toContain("ersten"); + }); + + it("is silent with a single check-in", () => { + expect( + render({ + ...TREND, + n: 1, + previous: null, + changeFromPrevious: null, + changeFromFirst: null, + sincePrevious: null, + sinceFirst: null, + }), + ).toBe(""); + }); +}); diff --git a/src/components/mental-health/assessment-trend-note.tsx b/src/components/mental-health/assessment-trend-note.tsx new file mode 100644 index 000000000..f604ba1f1 --- /dev/null +++ b/src/components/mental-health/assessment-trend-note.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useFormatters, useTranslations } from "@/lib/i18n/context"; + +import type { AssessmentTrendDto } from "./types"; + +/** + * v1.44 — how the latest score compares with the previous check-in (and, + * from the third, with the first), against the change size studies treat as + * meaningful for this instrument. Every number and verdict is the server's; + * the wording stays neutral: a change, its size, and whether it reaches the + * threshold. No band words, no advice. + */ +export function AssessmentTrendNote({ trend }: { trend: AssessmentTrendDto }) { + const { t } = useTranslations(); + const { date: formatDate, number } = useFormatters(); + if (trend.previous === null || trend.changeFromPrevious === null) return null; + + const signed = (delta: number) => + delta > 0 ? `+${number(delta, 0)}` : number(delta, 0); + const line = ( + key: "sincePrevious" | "sinceFirst", + at: string, + delta: number, + verdict: NonNullable, + ) => + `${t(`mentalHealth.trend.${key}`, { + date: formatDate(at), + change: signed(delta), + })} ${t(`mentalHealth.trend.verdict.${verdict}`, { + threshold: trend.threshold, + })}`; + + return ( +
+ {trend.sincePrevious ? ( +

+ {line( + "sincePrevious", + trend.previous.takenAt, + trend.changeFromPrevious, + trend.sincePrevious, + )} +

+ ) : null} + {trend.n >= 3 && trend.sinceFirst && trend.changeFromFirst !== null ? ( +

+ {line( + "sinceFirst", + trend.first.takenAt, + trend.changeFromFirst, + trend.sinceFirst, + )} +

+ ) : null} +
+ ); +} diff --git a/src/components/mental-health/instrument-detail.tsx b/src/components/mental-health/instrument-detail.tsx index fb1b2d36b..4e798ee49 100644 --- a/src/components/mental-health/instrument-detail.tsx +++ b/src/components/mental-health/instrument-detail.tsx @@ -21,16 +21,20 @@ import { relativeCalendarDate } from "@/lib/i18n/relative-time"; import { INSTRUMENTS } from "@/lib/mental-health/instruments"; import { AssessmentHistory } from "./assessment-history"; -import type { AssessmentRow, InstrumentId } from "./types"; +import type { AssessmentRow, AssessmentTrendDto, InstrumentId } from "./types"; +import { AssessmentTrendNote } from "./assessment-trend-note"; export function InstrumentDetail({ instrument, rows, + trend, onStart, }: { instrument: InstrumentId; /** ALL assessments; the pinned history filters to this instrument. */ rows: AssessmentRow[]; + /** v1.44 — the server's change against the meaningful-change threshold. */ + trend?: AssessmentTrendDto; /** Start a check-in for this instrument (closes the host sheet). */ onStart: () => void; }) { @@ -75,6 +79,8 @@ export function InstrumentDetail({
)} + {trend ? : null} + {/* The Start action stays reachable here too — the detail is a calm reading surface, not a dead end. */} {canManageMind && ( diff --git a/src/components/mental-health/mental-wellbeing.tsx b/src/components/mental-health/mental-wellbeing.tsx index 3919ed1e6..a6341fb47 100644 --- a/src/components/mental-health/mental-wellbeing.tsx +++ b/src/components/mental-health/mental-wellbeing.tsx @@ -45,6 +45,7 @@ import { InstrumentCard } from "./instrument-card"; import { InstrumentDetail } from "./instrument-detail"; import type { AssessmentRow, + AssessmentTrendDto, CreateResponse, InstrumentId, Phase, @@ -81,9 +82,10 @@ export function MentalWellbeing() { } = useQuery({ queryKey: queryKeys.mentalHealthAssessments(), queryFn: () => - apiGetList<{ assessments: AssessmentRow[] }>( - "/api/mental-health/assessments", - ), + apiGetList<{ + assessments: AssessmentRow[]; + trends?: AssessmentTrendDto[]; + }>("/api/mental-health/assessments"), }); const history = historyPage?.data; @@ -229,6 +231,9 @@ export function MentalWellbeing() { trend.instrument === detailInstrument, + )} onStart={() => begin(detailInstrument)} /> )} diff --git a/src/components/mental-health/types.ts b/src/components/mental-health/types.ts index 26d47f766..b5b679876 100644 --- a/src/components/mental-health/types.ts +++ b/src/components/mental-health/types.ts @@ -48,3 +48,18 @@ export interface CreateResponse { actionThreshold: number; crisis: CrisisSet | null; } + +/** v1.44 — `GET /api/mental-health/assessments` `trends[]` (`computeAssessmentTrends`). */ +export interface AssessmentTrendDto { + instrument: "PHQ9" | "GAD7" | "WHO5"; + n: number; + threshold: number; + higherIsBetter: boolean; + first: { score: number; takenAt: string }; + previous: { score: number; takenAt: string } | null; + latest: { score: number; takenAt: string }; + changeFromPrevious: number | null; + changeFromFirst: number | null; + sincePrevious: "improved" | "worsened" | "within_threshold" | null; + sinceFirst: "improved" | "worsened" | "within_threshold" | null; +} diff --git a/src/components/module-list/module-order-editor.tsx b/src/components/module-list/module-order-editor.tsx index f1d20c54c..d67a20743 100644 --- a/src/components/module-list/module-order-editor.tsx +++ b/src/components/module-list/module-order-editor.tsx @@ -110,7 +110,7 @@ export function ModuleOrderEditor({ items, onChange }: ModuleOrderEditorProps) { // `relative` anchors the sr-only drag-hint paragraph: `sr-only` is // absolutely positioned, and without a positioned ancestor its 1px box // lands below the list in DOCUMENT coordinates and makes the document - // itself scrollable (second scrollbar — UI-STANDARDS §9 one-scroll-floor). + // itself scrollable (second scrollbar — ui-standards.md §9 one-scroll-floor).
diff --git a/src/components/settings/security-section/security-keys-card.tsx b/src/components/settings/security-section/security-keys-card.tsx index ce4b99415..8bb5c0a8a 100644 --- a/src/components/settings/security-section/security-keys-card.tsx +++ b/src/components/settings/security-section/security-keys-card.tsx @@ -13,18 +13,8 @@ import { X, } from "lucide-react"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { ConfirmButton } from "@/components/ui/confirm-button"; import { Input } from "@/components/ui/input"; import { EmptyState } from "@/components/ui/empty-state"; import { SettingsCard } from "@/components/settings/settings-card"; @@ -308,46 +298,23 @@ export function SecurityKeysCard({ > - - - - - - - - {t("settings.security.keys.remove")} - - - {t("settings.security.keys.removeConfirm")} - - - - - {t("common.cancel")} - - remove.mutate(key.id)} - > - {remove.isPending && ( - - )} - {t("common.delete")} - - - - + remove.mutate(key.id)} + pending={ + remove.isPending && remove.variables === key.id + } + disabled={remove.isPending} + size="icon" + variant="ghost" + className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0" + icon={} + destructive + /> )} diff --git a/src/components/settings/security-section/totp-card.tsx b/src/components/settings/security-section/totp-card.tsx index 17e62753f..2acac8456 100644 --- a/src/components/settings/security-section/totp-card.tsx +++ b/src/components/settings/security-section/totp-card.tsx @@ -498,6 +498,7 @@ export function TotpCard({ {t("common.cancel")} >> +> = { + // v1.44 — sessions, trusted devices and the security activity joined the + // other sign-in controls under Security; the duplicate export card left. + "/settings/privacy": { + sessions: "/settings/security#sessions", + "trusted-devices": "/settings/security#trusted-devices", + "security-activity": "/settings/security#security-activity", + "your-data-export": "/settings/export", + }, + // v1.44 — the proactive nudges are a reminder and live with the others. + "/settings/coach": { + "coach-nudge": "/settings/notifications#coach-nudge", + }, + // v1.44 — Arrangement holds order and view only; each module's own + // settings moved to its page under Modules. + "/settings/layout/labs": { + "labs-local-ocr": "/settings/modules/labs#labs-local-ocr", + "labs-biomarkers": "/settings/modules/labs#labs-biomarkers", + }, + "/settings/layout/medications": { + "injection-sites": "/settings/modules/medications#injection-sites", + }, + "/settings/layout/vorsorge": { + "screening-hints": "/settings/anamnesis#screening-hints", + "vorsorge-reminders": "/settings/notifications#vorsorge-reminders", + }, +}; + +/** Where an anchor on a page now lives, or null when it did not move. */ +export function forwardedAnchorTarget( + pathname: string, + hash: string, +): string | null { + const anchor = hash.startsWith("#") ? hash.slice(1) : hash; + if (!anchor) return null; + return SETTINGS_ANCHOR_FORWARDS[pathname]?.[anchor] ?? null; +} + +/** + * Forward now, and again whenever only the hash changes: a same-page link to + * a moved anchor is no navigation, so an arrival check alone never sees it. + * Returns the unsubscribe. + */ +export function watchAnchorForwards( + pathname: string, + replace: (target: string) => void, + win: Pick & { + location: { hash: string }; + } = window, +): () => void { + const forward = () => { + const target = forwardedAnchorTarget(pathname, win.location.hash); + if (target) replace(target); + }; + forward(); + win.addEventListener("hashchange", forward); + return () => win.removeEventListener("hashchange", forward); +} + +export function SettingsAnchorForward() { + const pathname = usePathname(); + const router = useRouter(); + + useEffect(() => { + if (!pathname) return; + return watchAnchorForwards(pathname, (target) => router.replace(target)); + }, [pathname, router]); + + return null; +} diff --git a/src/components/settings/settings-mobile-nav.tsx b/src/components/settings/settings-mobile-nav.tsx new file mode 100644 index 000000000..0eddd5fb3 --- /dev/null +++ b/src/components/settings/settings-mobile-nav.tsx @@ -0,0 +1,131 @@ +"use client"; + +/** + * Settings navigation below `md`: one button naming where the person is, and a + * bottom sheet listing every section under its group. + * + * The phone used to carry every section as a chip in one horizontal strip, + * more than twenty of them, so most of Settings sat off screen and the groups + * the desktop sidebar shows were reduced to thin dividers. The sheet shows the + * same groups and order as the sidebar, one 44 px row per section, and closes + * on choice. Both read the visible-section list the shell computes, so the + * module and record filters apply to both alike. + */ + +import * as React from "react"; +import Link from "next/link"; +import { ChevronsUpDown } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { useTranslations } from "@/lib/i18n/context"; + +export interface SettingsNavEntry { + slug: string; + title: string; + groupLabel: string; + icon: LucideIcon; +} + +export function SettingsMobileNav({ + entries, + activeSlug, +}: { + entries: readonly SettingsNavEntry[]; + activeSlug: string; +}) { + const { t } = useTranslations(); + const [open, setOpen] = React.useState(false); + const active = entries.find((entry) => entry.slug === activeSlug); + const ActiveIcon = active?.icon; + + // Group the flat, pre-ordered list for rendering; the order of first + // appearance is the group order. + const groups: { label: string; entries: SettingsNavEntry[] }[] = []; + for (const entry of entries) { + const last = groups.at(-1); + if (last && last.label === entry.groupLabel) last.entries.push(entry); + else groups.push({ label: entry.groupLabel, entries: [entry] }); + } + + return ( + + ); +} diff --git a/src/components/settings/settings-section-loader.tsx b/src/components/settings/settings-section-loader.tsx index 447a8b387..41aecb597 100644 --- a/src/components/settings/settings-section-loader.tsx +++ b/src/components/settings/settings-section-loader.tsx @@ -11,8 +11,8 @@ import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; /** * One settings section, loaded on its own. * - * The section route renders exactly one of twenty-one sections, but importing - * them statically put all twenty-one (and everything under them: the import + * The section route renders exactly one of the sections, but importing + * them statically put all of them (and everything under them: the import * panel, the integration cards, the MCP and API token tables, the score * editor) into the route's entry bundle, so opening "About" downloaded the * whole settings tree. Each section is now its own chunk. A first load still diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 1dbb1e32e..29c8b1af2 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -9,9 +9,10 @@ * by the page itself. * * Mobile-first per `docs/ui-guidelines.md`: - * - =md: sticky 220px sidebar + content column with `max-w-screen-xl`. + * - =md: sticky 220px sidebar, grouped the same way, beside a content + * column with `max-w-screen-xl`. */ import * as React from "react"; @@ -56,6 +57,8 @@ import { type SettingsSectionSlug, } from "./section-slugs"; import { SettingsHubBackLink } from "./settings-hub-back-link"; +import { SettingsMobileNav } from "./settings-mobile-nav"; +import { SettingsAnchorForward } from "./settings-anchor-forward"; // Re-export so existing call-sites importing from this module keep working. // New server-side call-sites (e.g. `generateStaticParams()`) should import @@ -71,18 +74,24 @@ export { }; /** - * The sidebar's 19 entries render under one - * of these seven non-interactive group headers instead of as one flat - * list. Presentation only: no slug, route, or redirect changes. Order - * below is the render order of the groups themselves. + * The nine groups every section belongs to, in render order. The sidebar + * paints a non-interactive header above the first visible entry of each, and + * the phone's section sheet uses the same headers. + * + * Account: who you are and who can get in. Tracking: what the app records. + * Arrangement: order and view only. Notifications: every reminder. Health + * profile: what describes you. AI and Connections: what talks to the outside. + * Data: export, import, privacy and deletion. System: previews and about. */ export type SettingsSectionGroup = | "account" - | "yourData" - | "display" + | "tracking" + | "arrangement" + | "notifications" | "healthProfile" | "ai" - | "connectivity" + | "connections" + | "data" | "system"; /** i18n key for each group's header label, under `settings.shell.groups.*`. */ @@ -91,11 +100,13 @@ export const SETTINGS_SECTION_GROUP_LABEL_KEYS: Record< string > = { account: "settings.shell.groups.account", - yourData: "settings.shell.groups.yourData", - display: "settings.shell.groups.display", + tracking: "settings.shell.groups.tracking", + arrangement: "settings.shell.groups.arrangement", + notifications: "settings.shell.groups.notifications", healthProfile: "settings.shell.groups.healthProfile", ai: "settings.shell.groups.ai", - connectivity: "settings.shell.groups.connectivity", + connections: "settings.shell.groups.connections", + data: "settings.shell.groups.data", system: "settings.shell.groups.system", }; @@ -109,159 +120,84 @@ interface SettingsSection { } /** - * Source of truth for the section list that the settings shell renders - * in its sidebar + mobile chip-strip. + * Source of truth for the section list that the settings shell renders in + * its sidebar and in the phone's section sheet, pre-ordered by group. * - * The list used to render in a single flat - * order with no visual structure (19 entries deep, the AI cluster and the - * programmatic-access cluster interleaved, the health-profile pair split - * across the list). It is now clustered into the seven - * `SettingsSectionGroup`s, each entry carrying its `group`; the list is - * pre-ordered by group and the shell inserts a non-interactive header - * before the first VISIBLE entry of each group. This is a presentation-only - * reorder — no slug, route, or redirect changed, and `SETTINGS_SECTION_SLUGS` - * (`section-slugs.ts`, which also carries the page-only slugs and drives - * `generateStaticParams()`) is untouched. + * `SETTINGS_SECTION_SLUGS` (`section-slugs.ts`) is the route registry and + * keeps its own order (it drives `generateStaticParams()` and the record + * landing order); this list decides only where each entry is shown. Every + * slug a person ever reached keeps its URL: a section that moved between + * groups moved here, not in the route. * - * "About" sits at the end of the list (System group). v1.4.33 IW7 had - * folded it into the sidebar user-card dropdown only, which left - * `/settings/about` an orphaned route — reachable by URL but discoverable - * nowhere in the settings navigation. It is now both a regular shell entry - * and a user-card dropdown item ("Über HealthLog" / "About HealthLog"). + * The card titles people cite stay as they are: "Export & import", + * "Integrations", "API tokens", "Shared access", "Modules". */ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ - // ── Account ── + // ── Account ── profile and display, sign-in and sessions, who else may open + // this record. { slug: "account", titleKey: "settings.sections.account.title", icon: User, group: "account", }, - // v1.23 — the account-security home: second factors, recovery codes, - // passkey management. + // Second factors, passkeys, password, and (since v1.44) the active + // sessions, trusted devices and security activity that used to sit on the + // privacy page. { slug: "security", titleKey: "settings.sections.security.title", icon: ShieldCheck, group: "account", }, - // v1.36.0 — who else may open this record, and whose this account may open. - // In the Account group beside Security: both answer "who can get at my - // account and what have they done", and a person looking for one will look - // where the other is. { slug: "access", titleKey: "settings.sections.access.title", icon: UsersRound, group: "account", }, - // v1.23 — "Data & Privacy" assembles the export / deletion / retention / - // encryption / session / activity pieces into one coherent privacy pane. - { - slug: "privacy", - titleKey: "settings.sections.privacy.title", - icon: Lock, - group: "account", - }, - // ── Your data ── - // v1.18.0 — the "Was du trackst" hub: the front door for enabling / - // disabling the secondary tracking domains. + // ── Tracking ── which areas are recorded; each module's own settings + // (biomarkers, mood tags, injection sites) open from its row. { slug: "modules", titleKey: "settings.sections.modules.title", icon: Blocks, - group: "yourData", + group: "tracking", }, - { - slug: "integrations", - titleKey: "settings.sections.integrations.title", - icon: Link2, - group: "yourData", - }, - // v1.18.1 — Quellen (source weighting): which connection wins when - // two report the same metric. v1.25.3 — Kanäle (delivery channels) folded - // back into Notifications as an in-page group; - // `/settings/channels` 301-redirects to `/settings/notifications#channels`. - { - slug: "sources", - titleKey: "settings.sections.sources.title", - icon: Layers, - group: "yourData", - }, - // v1.18.0 — the full health record (PDF + FHIR R4 + zip package). - // v1.18.6.1 — NOT module-gated: the health record is a flagship export - // capability; gating its only entry-point on the `doctorReport` opt-out - // meant a stray toggle in the Modules hub made the whole section vanish - // with no in-context way back. The `doctorReport` module still governs - // the data layer server-side (403 `module.disabled` over Bearer). - { - slug: "gesundheitsakte", - titleKey: "settings.sections.gesundheitsakte.title", - icon: FileHeart, - group: "yourData", - }, - // v1.25.7 — clinician share links fold into the Gesundheitsakte section as - // a labelled "Sharing" group; `/settings/sharing` 301-redirects there. - { - slug: "export", - titleKey: "settings.sections.export.title", - icon: Download, - group: "yourData", - }, - // ── Display ── - // v1.17.1 — one nav entry replaces the four scattered Dashboard / - // Insights / Medications / Mood "arrange" entries; those routes still - // resolve, reached through this hub. v1.25.3 — renamed to "Appearance" / - // "Darstellung" and widened into the index for every module's - // view/sort/order surface (the slug stays `layout`). v1.25.11 (#148) — - // each module lives on its own subpage at `/settings/layout/`; - // the old per-module routes 301-redirect there. + // ── Arrangement ── order and view, nothing else. The slug stays `layout`. { slug: "layout", titleKey: "settings.sections.layout.title", icon: Palette, - group: "display", + group: "arrangement", }, - // v1.25.7 — Vorsorge (preventive-care reminders) folded into - // "Darstellung"; `/settings/vorsorge` 301-redirects to - // `/settings/layout/vorsorge`. - { - slug: "thresholds", - titleKey: "settings.sections.thresholds.title", - icon: SlidersHorizontal, - group: "display", - }, - // v1.18.0 — "Benachrichtigungen" is the single module-gated - // reminder-types home; the standalone "Erinnerungen" hub is gone. + // ── Notifications ── every reminder kind, or the link to where it lives. { slug: "notifications", titleKey: "settings.sections.notifications.title", icon: Bell, - group: "display", + group: "notifications", }, // ── Health profile ── - // v1.35.0 — which pillars count toward the Health Score. Sits at the top - // of the health-profile cluster: it is a statement about the person's own - // health picture, not about how the app looks or what it records. { slug: "score", titleKey: "settings.sections.score.title", icon: Gauge, group: "healthProfile", }, - // v1.25 — Anamnese (medical history): the structured-record - // home for allergies + family history. NOT module-gated (like Vorsorge) — - // these are foundational health-profile records, always available. + { + slug: "thresholds", + titleKey: "settings.sections.thresholds.title", + icon: SlidersHorizontal, + group: "healthProfile", + }, { slug: "anamnesis", titleKey: "settings.sections.anamnesis.title", icon: ClipboardList, group: "healthProfile", }, - // v1.25 — Environmental context: home location, travel overrides, - // and the weather/daylight backfill. Module-gated on the opt-in - // `environment` module (`settings:environment` in the surface map), so the - // entry only appears once the user turns it on. + // Module-gated on the opt-in `environment` module. { slug: "environment", titleKey: "settings.sections.environment.title", @@ -275,39 +211,69 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ icon: Sparkles, group: "ai", }, - // v1.18.0 — Coach: the Coach preference cards get their own entry, - // shown only when the coach module is enabled (`settings:coach` in the - // surface map). The AI entry above keeps - // provider / model / BYOK configuration. + // Module-gated on the coach module (`settings:coach`). { slug: "coach", titleKey: "settings.sections.coach.title", icon: Bot, group: "ai", }, - // ── Connectivity ── + // ── Connections ── data coming in (integrations, which source wins) and + // programmatic access going out (tokens, the MCP connector). + { + slug: "integrations", + titleKey: "settings.sections.integrations.title", + icon: Link2, + group: "connections", + }, + { + slug: "sources", + titleKey: "settings.sections.sources.title", + icon: Layers, + group: "connections", + }, { slug: "api", titleKey: "settings.sections.api.title", icon: KeyRound, - group: "connectivity", + group: "connections", }, - // v1.22.0 — remote MCP connector. Not module-gated: the card carries its - // own enable toggle (mirroring `gesundheitsakte`), so the entry-point - // stays reachable even with the opt-in module off. + // Not module-gated: the page shows the connector's state and links to the + // one switch in Modules. { slug: "mcp", titleKey: "settings.sections.mcp.title", icon: Plug, - group: "connectivity", + group: "connections", + }, + // ── Data ── getting data out and in, what is stored, and deleting it. + { + slug: "export", + titleKey: "settings.sections.export.title", + icon: Download, + group: "data", + }, + // Not module-gated: the health record is a flagship export, and its only + // entry point must not vanish behind the `doctorReport` opt-out. + { + slug: "gesundheitsakte", + titleKey: "settings.sections.gesundheitsakte.title", + icon: FileHeart, + group: "data", + }, + { + slug: "privacy", + titleKey: "settings.sections.privacy.title", + icon: Lock, + group: "data", }, - // ── System ── { slug: "advanced", titleKey: "settings.sections.advanced.title", icon: Settings2, - group: "system", + group: "data", }, + // ── System ── { slug: "about", titleKey: "settings.sections.about.title", @@ -453,40 +419,6 @@ export function SettingsShell({ (section) => section.slug === activeSlug, ); - // v1.4.33 — keep the active chip in view inside the horizontal - // mobile strip. On a 393 CSS px viewport the strip is wider than the - // viewport and the rightmost chips sit off-screen; a user who tapped - // one of those chips lands on the new route with the strip still - // scrolled to the leftmost chip, which reads as if the navigation is - // broken. We pin the active chip to the left edge of the strip. - // - // v1.7.0 — adjust only the strip's own horizontal scroll offset - // (via `scrollTo({ left })`) instead of `Element.scrollIntoView()`. - // `scrollIntoView` walks every scrollable ancestor and adjusts both - // axes, so on mobile it also nudged the whole document vertically on - // each route change — a dizzy auto-scroll that fired on every settings - // sub-page tap. Scrolling the strip's own `scrollLeft` confines the - // motion to its horizontal axis and never touches the page scroll - // position. - const mobileStripRef = React.useRef(null); - React.useEffect(() => { - if (typeof window === "undefined") return; - const strip = mobileStripRef.current; - if (!strip) return; - const active = strip.querySelector('[aria-current="page"]'); - if (!active) return; - // Land the active chip at the strip's left edge, clamped to the - // scrollable range. `offsetLeft` is relative to the strip's padding - // box, which is the origin for `scrollLeft`. - const maxScroll = strip.scrollWidth - strip.clientWidth; - const target = Math.max(0, Math.min(active.offsetLeft, maxScroll)); - // Position the active chip instantly. A smooth behaviour here animates - // the strip from its reset `scrollLeft: 0` to the target on every tap, - // which reads as an unwanted "scroll from the start" sweep when swapping - // sections. An instant jump keeps the chip in view without the sweep. - strip.scrollTo({ left: target, behavior: "auto" }); - }, [activeSlug]); - // v1.18.6.1 — resolve the heading once. Pages pass an explicit `heading` // (the canonical path); the slug fallback keeps the shell renderable on // its own (and in tests that mount it without a heading) by deriving the @@ -549,64 +481,18 @@ export function SettingsShell({
{headingBlock(true)}
) : null} - {/* Mobile section strip — horizontal scroll, hidden on md+. - `no-scrollbar` (defined in `globals.css`) suppresses the - painted scrollbar; the horizontal swipe + keyboard arrow - scrolling still work. Without it the 10-section strip - renders an always-on scrollbar at the top of every settings - page, which makes the page feel like every section card has - an overflow problem. - - v1.4.33 — `snap-x snap-mandatory` lets a swipe-flick land - on the next chip's leading edge instead of the in-between - dead zone. The auto `scrollIntoView({inline: "center"})` - effect above stays the canonical positioner for the active - chip; snap is the polish layer when the user manually flicks - through the strip without tapping. */} - + + {/* Below `md`: one button naming the current section, opening the + grouped section sheet. Hidden on md+, where the sidebar shows. */} + ({ + slug: section.slug, + title: t(section.titleKey), + groupLabel: t(SETTINGS_SECTION_GROUP_LABEL_KEYS[section.group]), + icon: section.icon, + }))} + /> {/* v1.18.6.1 — two-row grid on desktop: row 1 / col 2 = heading (content column only) diff --git a/src/components/settings/share-link-create-form.tsx b/src/components/settings/share-link-create-form.tsx index 583e26877..2bd1a8382 100644 --- a/src/components/settings/share-link-create-form.tsx +++ b/src/components/settings/share-link-create-form.tsx @@ -518,7 +518,7 @@ export function ShareLinkCreateForm({ opens the record (and its shared documents). Promoted as the primary mobile affordance: a clinician scans it on the spot to open the record on their own device. Shown exactly once. The - white quiet-zone is the documented QR exemption (UI-STANDARDS §4) + white quiet-zone is the documented QR exemption (ui-standards.md §4) so the code stays scannable in the dark theme too. */} {qrDataUrl && (
- - - - - - - {t("settings.sharing.revoke")} - - - {t("settings.sharing.revokeDescription")} - - - - - {t("common.cancel")} - - revokeMutation.mutate(link.id)} - > - {t("settings.sharing.revoke")} - - - - + revokeMutation.mutate(link.id)} + pending={ + revokeMutation.isPending && + revokeMutation.variables === link.id + } + size="sm" + className="ml-auto flex min-h-11 w-fit sm:min-h-9" + icon={} + destructive + /> ))} diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx index 1860af9dc..b6d7f51ae 100644 --- a/src/components/settings/sources-section.tsx +++ b/src/components/settings/sources-section.tsx @@ -679,6 +679,19 @@ export function SourcesSection() { {t("settings.sections.sources.integrationsHintLink")}

+ {/* v1.44 (#1204) — the read-only view of what this ladder decides: + which source delivered what on a day, and two sources side by + side. Above the action row, like the hint before it. */} +

+ {t("sourceProvenance.settingsHint")}{" "} + + {t("sourceProvenance.settingsHintLink")} + +

{/* One action row, last in the card (design standards §12). The reset used to ride the header's status slot, which carries @@ -697,6 +710,7 @@ export function SourcesSection() { disabled={saveMutation.isPending} pending={resetMutation.isPending} onConfirm={() => resetMutation.mutate()} + destructive /> {dirty && priority ? ( <> diff --git a/src/components/settings/telegram-card.tsx b/src/components/settings/telegram-card.tsx index 3c685ce6b..e989554d5 100644 --- a/src/components/settings/telegram-card.tsx +++ b/src/components/settings/telegram-card.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Info, Loader2, MessageCircle, Save } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -15,11 +15,8 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { TestConnectionButton } from "@/components/settings/test-connection-button"; import { isTelegramFormDirty } from "@/components/settings/channel-form-dirty"; -import { - optimisticallySetChannelEnabled, - persistChannelEnabled, - rollbackChannelEnabled, -} from "@/components/settings/notification-channel-toggle"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { useChannelEnabledToggle } from "@/components/settings/notification-channel-toggle"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; @@ -40,8 +37,7 @@ export function TelegramCard({ const [botToken, setBotToken] = useState(""); const [chatId, setChatId] = useState(""); const [saving, setSaving] = useState(false); - const [msg, setMsg] = useState(null); - const [msgType, setMsgType] = useState<"success" | "error" | null>(null); + const [error, setError] = useState(null); const { data: settings } = useQuery({ queryKey: queryKeys.telegramSettings(), @@ -52,9 +48,7 @@ export function TelegramCard({ }); // Sync from server using the React-recommended "previous-payload-id" - // pattern instead of setState-in-effect. This avoids the strict - // `react-hooks/set-state-in-effect` lint failure that the legacy file - // disabled at module scope. + // pattern instead of setState-in-effect. const settingsKey = settings ? (settings.chatId ?? "") : null; const [seededKey, setSeededKey] = useState(null); if (settingsKey && settingsKey !== seededKey) { @@ -62,53 +56,16 @@ export function TelegramCard({ if (settings!.chatId) setChatId(settings!.chatId); } const dirty = isTelegramFormDirty({ botToken, chatId }, settings); - - const toggleEnabled = useMutation< - void, - Error, - boolean, - TelegramSettings | undefined - >({ - mutationFn: (enabled) => - persistChannelEnabled( - "/api/settings/telegram", - enabled, - t("settings.savingError"), - ), - onMutate: async (enabled) => { - const queryKey = queryKeys.telegramSettings(); - await queryClient.cancelQueries({ queryKey }); - setMsg(null); - setMsgType(null); - return optimisticallySetChannelEnabled( - queryClient, - queryKey, - enabled, - ); - }, - onSuccess: () => { - setMsg(t("settings.saved")); - setMsgType("success"); - }, - onError: (err, _enabled, previous) => { - rollbackChannelEnabled( - queryClient, - queryKeys.telegramSettings(), - previous, - ); - setMsg(err.message); - setMsgType("error"); - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.telegramSettings() }); - }, - }); + // The enable switch saves on tap; the form's Save covers token and chat. + const toggle = useChannelEnabledToggle( + "/api/settings/telegram", + queryKeys.telegramSettings(), + ); async function handleSave(e: React.FormEvent) { e.preventDefault(); setSaving(true); - setMsg(null); - setMsgType(null); + setError(null); const body: Record = { enabled: settings?.enabled ?? false, @@ -126,16 +83,15 @@ export function TelegramCard({ }).catch(() => null); if (res?.ok) { - setMsg(t("settings.saved")); - setMsgType("success"); + toastWrittenOutcome("success", t("settings.saved")); setBotToken(""); queryClient.invalidateQueries({ queryKey: queryKeys.telegram() }); } else { const json = (await res?.json().catch(() => null)) as { error?: string; } | null; - setMsg(json?.error || t("settings.savingError")); - setMsgType("error"); + // A form shows its refusal inline, next to the fields it is about. + setError(json?.error || t("settings.savingError")); } } finally { setSaving(false); @@ -151,6 +107,18 @@ export function TelegramCard({ />
+
+ + toggle.mutate(checked)} + disabled={!settings || saving || toggle.isPending} + /> +
+
@@ -189,18 +157,6 @@ export function TelegramCard({
-
- toggleEnabled.mutate(checked)} - disabled={saving || toggleEnabled.isPending} - /> - -
-
  1. {t("settings.telegramStep1")}
  2. @@ -209,14 +165,9 @@ export function TelegramCard({
- {msg && (msgType === "error" || !dirty) && ( -

- {msg} + {error && ( +

+ {error}

)} @@ -228,7 +179,7 @@ export function TelegramCard({ />
diff --git a/src/components/settings/time-format-select.tsx b/src/components/settings/time-format-select.tsx index d8c0d823e..528c5e37d 100644 --- a/src/components/settings/time-format-select.tsx +++ b/src/components/settings/time-format-select.tsx @@ -1,8 +1,11 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { showErrorToast } from "@/lib/errors/error-toast"; + import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; import { useAuth } from "@/hooks/use-auth"; @@ -39,30 +42,6 @@ export function TimeFormatSelect({ const [optimistic, setOptimistic] = useState( null, ); - const [msg, setMsg] = useState(null); - const [msgType, setMsgType] = useState<"success" | "error" | null>(null); - const clearTimerRef = useRef | null>(null); - - useEffect(() => { - return () => { - if (clearTimerRef.current !== null) { - clearTimeout(clearTimerRef.current); - clearTimerRef.current = null; - } - }; - }, []); - - function scheduleClear() { - if (clearTimerRef.current !== null) { - clearTimeout(clearTimerRef.current); - } - clearTimerRef.current = setTimeout(() => { - clearTimerRef.current = null; - setMsg(null); - setMsgType(null); - }, 3000); - } - const value: TimeFormatPreference = optimistic ?? user?.timeFormat ?? "AUTO"; const mutation = useMutation({ @@ -84,31 +63,19 @@ export function TimeFormatSelect({ // Repaint every formatter consumer right away; the /me refetch below // re-asserts the same value once it settles. storeTimeFormat(next); - setMsg(t("settings.timeFormat.saved")); - setMsgType("success"); + toastWrittenOutcome("success", t("settings.timeFormat.saved")); queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); setOptimistic(null); - scheduleClear(); }, onError: (err) => { setOptimistic(null); - setMsg( - err instanceof Error ? err.message : t("settings.timeFormat.saveError"), - ); - setMsgType("error"); - scheduleClear(); + showErrorToast(t("settings.timeFormat.saveError"), { cause: err }); }, }); function handleSelect(next: TimeFormatPreference) { if (next === value || mutation.isPending || !isAuthenticated) return; setOptimistic(next); - setMsg(null); - setMsgType(null); - if (clearTimerRef.current !== null) { - clearTimeout(clearTimerRef.current); - clearTimerRef.current = null; - } mutation.mutate(next); } @@ -126,16 +93,8 @@ export function TimeFormatSelect({ -

- {msg ?? t("settings.timeFormat.description")} +

+ {t("settings.timeFormat.description")}

); diff --git a/src/components/settings/timezone-select.tsx b/src/components/settings/timezone-select.tsx new file mode 100644 index 000000000..e7b481304 --- /dev/null +++ b/src/components/settings/timezone-select.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { resolveInitialTimezone } from "@/components/settings/account-section/account-section-utils"; +import { TimezonePicker } from "@/components/settings/timezone-picker"; +import { InlineAction } from "@/components/ui/inline-action"; +import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; +import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { showErrorToast } from "@/lib/errors/error-toast"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { storeTimezone } from "@/lib/timezone-mirror"; +import { DEFAULT_TIMEZONE, detectBrowserTimezone } from "@/lib/tz/format"; + +/** + * The profile timezone as a Display preference: it saves the moment it + * changes, like the language, units and formats beside it. + * + * Until v1.44 it sat in the profile form and waited for that form's Save, + * which made it the one display preference that did not take effect on + * change. Persistence is unchanged (`PUT /api/auth/me/timezone`, the route + * that owns the IANA validation). The local mirror is written on success so + * every rendered timestamp repaints in the same tab before the `/me` + * refetch confirms it (issue #490). + * + * The select shows the stored zone: a value on screen is a value that is + * saved. The form used to pre-fill the device's zone over the untouched + * default and save it with the next profile save; that suggestion stays, + * as a one-tap offer under the select (`resolveInitialTimezone` decides + * when), instead of an unsaved value dressed as the current one. + */ +export function TimezoneSelect({ + isAuthenticated, +}: { + isAuthenticated: boolean; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const { user } = useAuth(); + const [optimistic, setOptimistic] = useState(null); + + const value = optimistic ?? user?.timezone ?? DEFAULT_TIMEZONE; + + const mutation = useMutation({ + mutationFn: async (next: string) => { + const res = await apiFetchRaw("/api/auth/me/timezone", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ timezone: next }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return next; + }, + onSuccess: (next) => { + storeTimezone(next); + toastWrittenOutcome("success", t("settings.timezoneSaved")); + void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); + setOptimistic(null); + }, + onError: (err) => { + setOptimistic(null); + showErrorToast(t("settings.timezoneInvalid"), { cause: err }); + }, + }); + + function handleChange(next: string) { + if (next === value || mutation.isPending || !isAuthenticated) return; + setOptimistic(next); + mutation.mutate(next); + } + + // The device zone is read after hydration so both renders agree. + const mounted = useMounted(); + const suggestion = mounted + ? resolveInitialTimezone(user?.timezone, detectBrowserTimezone()) + : value; + + return ( +
+ + {suggestion !== value && optimistic === null ? ( + handleChange(suggestion)} + > + {t("settings.timezoneUseDevice", { zone: suggestion })} + + ) : null} +
+ ); +} diff --git a/src/components/settings/unit-preference-select.tsx b/src/components/settings/unit-preference-select.tsx index 43a435715..0ba6d4712 100644 --- a/src/components/settings/unit-preference-select.tsx +++ b/src/components/settings/unit-preference-select.tsx @@ -1,8 +1,11 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { showErrorToast } from "@/lib/errors/error-toast"; + import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; import { useAuth } from "@/hooks/use-auth"; @@ -36,30 +39,6 @@ export function UnitPreferenceSelect({ const { user } = useAuth(); const [optimistic, setOptimistic] = useState(null); - const [msg, setMsg] = useState(null); - const [msgType, setMsgType] = useState<"success" | "error" | null>(null); - const clearTimerRef = useRef | null>(null); - - useEffect(() => { - return () => { - if (clearTimerRef.current !== null) { - clearTimeout(clearTimerRef.current); - clearTimerRef.current = null; - } - }; - }, []); - - function scheduleClear() { - if (clearTimerRef.current !== null) { - clearTimeout(clearTimerRef.current); - } - clearTimerRef.current = setTimeout(() => { - clearTimerRef.current = null; - setMsg(null); - setMsgType(null); - }, 3000); - } - const value: UnitPreference = optimistic ?? user?.unitPreference ?? "metric"; const mutation = useMutation({ @@ -78,36 +57,22 @@ export function UnitPreferenceSelect({ return next; }, onSuccess: () => { - setMsg(t("settings.dashboard.units.saved")); - setMsgType("success"); + toastWrittenOutcome("success", t("settings.dashboard.units.saved")); queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); queryClient.invalidateQueries({ queryKey: queryKeys.userUnitPreference(), }); setOptimistic(null); - scheduleClear(); }, onError: (err) => { setOptimistic(null); - setMsg( - err instanceof Error - ? err.message - : t("settings.dashboard.units.saveError"), - ); - setMsgType("error"); - scheduleClear(); + showErrorToast(t("settings.dashboard.units.saveError"), { cause: err }); }, }); function handleSelect(next: UnitPreference) { if (next === value || mutation.isPending || !isAuthenticated) return; setOptimistic(next); - setMsg(null); - setMsgType(null); - if (clearTimerRef.current !== null) { - clearTimeout(clearTimerRef.current); - clearTimerRef.current = null; - } mutation.mutate(next); } @@ -126,16 +91,8 @@ export function UnitPreferenceSelect({ {t("settings.dashboard.units.imperial")} -

- {msg ?? t("settings.dashboard.units.description")} +

+ {t("settings.dashboard.units.description")}

); diff --git a/src/components/settings/vorsorge-section.tsx b/src/components/settings/vorsorge-section.tsx index c6413b905..f6376bba1 100644 --- a/src/components/settings/vorsorge-section.tsx +++ b/src/components/settings/vorsorge-section.tsx @@ -9,17 +9,18 @@ * The shell supplies the page chrome (left nav, heading, subtitle) so this * body is the preventive-care cards only. Preventive-care reminders are not * a toggleable module, so this entry carries no `moduleGate` (always shown). + * + * v1.44 — order and view only. The reminder switches moved to Settings > + * Notifications, the screening hints to Health profile > Medical history + * beside the life circumstances they come from. */ import { VorsorgeSettings } from "@/components/measurement-reminders/vorsorge-settings"; -import { ScreeningHintsCard } from "@/components/records/screening-hints-card"; export function VorsorgeSection() { return (
- {/* v1.43 (#614) — hints from the life circumstances, with their source. */} -
); } diff --git a/src/components/settings/webhook-card.tsx b/src/components/settings/webhook-card.tsx index f09528c7e..644be17e5 100644 --- a/src/components/settings/webhook-card.tsx +++ b/src/components/settings/webhook-card.tsx @@ -15,11 +15,8 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { TestConnectionButton } from "@/components/settings/test-connection-button"; import { isWebhookFormDirty } from "@/components/settings/channel-form-dirty"; -import { - optimisticallySetChannelEnabled, - persistChannelEnabled, - rollbackChannelEnabled, -} from "@/components/settings/notification-channel-toggle"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { useChannelEnabledToggle } from "@/components/settings/notification-channel-toggle"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; @@ -41,10 +38,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) { const [headerName, setHeaderName] = useState(""); const [headerValue, setHeaderValue] = useState(""); const [format, setFormat] = useState("generic"); - const [saveMsg, setSaveMsg] = useState(null); - const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>( - null, - ); + const [saveError, setSaveError] = useState(null); const { data: settings } = useQuery({ queryKey: queryKeys.settingsWebhook(), @@ -69,9 +63,14 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) { { url, headerName, headerValue, format }, settings, ); + // The enable switch saves on tap; the form's Save covers the fields below. + const toggle = useChannelEnabledToggle( + "/api/settings/webhook", + queryKeys.settingsWebhook(), + ); const save = useMutation({ - mutationFn: async (enabled: boolean) => { + mutationFn: async () => { const res = await apiFetchRaw("/api/settings/webhook", { method: "PUT", headers: { "Content-Type": "application/json" }, @@ -80,7 +79,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) { headerName: headerName || undefined, headerValue: headerValue || undefined, format, - enabled, + enabled: settings?.enabled ?? false, }), }); if (!res.ok) { @@ -102,54 +101,13 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.settingsWebhook() }); - setSaveMsg(t("settings.saved")); - setSaveMsgType("success"); + setSaveError(null); + toastWrittenOutcome("success", t("settings.saved")); setHeaderValue(""); }, + // A form shows its refusal inline, next to the fields it is about. onError: (err: Error) => { - setSaveMsg(err.message); - setSaveMsgType("error"); - }, - }); - - const toggleEnabled = useMutation< - void, - Error, - boolean, - WebhookSettings | undefined - >({ - mutationFn: (enabled) => - persistChannelEnabled( - "/api/settings/webhook", - enabled, - t("common.error"), - ), - onMutate: async (enabled) => { - const queryKey = queryKeys.settingsWebhook(); - await queryClient.cancelQueries({ queryKey }); - setSaveMsg(null); - setSaveMsgType(null); - return optimisticallySetChannelEnabled( - queryClient, - queryKey, - enabled, - ); - }, - onSuccess: () => { - setSaveMsg(t("settings.saved")); - setSaveMsgType("success"); - }, - onError: (err, _enabled, previous) => { - rollbackChannelEnabled( - queryClient, - queryKeys.settingsWebhook(), - previous, - ); - setSaveMsg(err.message); - setSaveMsgType("error"); - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.settingsWebhook() }); + setSaveError(err.message); }, }); @@ -167,15 +125,15 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) { toggleEnabled.mutate(checked)} - disabled={save.isPending || toggleEnabled.isPending} + onCheckedChange={(checked) => toggle.mutate(checked)} + disabled={!settings || save.isPending || toggle.isPending} /> { e.preventDefault(); - save.mutate(settings?.enabled ?? false); + save.mutate(); }} className="space-y-4" > @@ -244,12 +202,9 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) { - {saveMsg && (saveMsgType === "error" || !dirty) && ( -

- {saveMsg} + {saveError && ( +

+ {saveError}

)} @@ -261,7 +216,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) { />

diff --git a/src/components/mood/manage/tag-manager-card.tsx b/src/components/mood/manage/tag-manager-card.tsx index 2bb3fa6cc..c5246873a 100644 --- a/src/components/mood/manage/tag-manager-card.tsx +++ b/src/components/mood/manage/tag-manager-card.tsx @@ -296,7 +296,7 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) { // `relative` anchors the sr-only drag-hint paragraph: `sr-only` is // absolutely positioned, and without a positioned ancestor its 1px box // lands below the list in DOCUMENT coordinates and makes the document - // itself scrollable (second scrollbar — UI-STANDARDS §9 one-scroll-floor). + // itself scrollable (second scrollbar — ui-standards.md §9 one-scroll-floor).

diff --git a/src/components/onboarding/step-heading.tsx b/src/components/onboarding/step-heading.tsx index 85b4645d7..cd8b9e1c8 100644 --- a/src/components/onboarding/step-heading.tsx +++ b/src/components/onboarding/step-heading.tsx @@ -13,7 +13,7 @@ import { useEffect, useRef, type ReactNode } from "react"; * put it — the heading is at the top of the column anyway. * * Semibold rather than the app-wide bold PageHeader H1: the onboarding hero - * is the documented exception (UI-STANDARDS §5). Do not sweep to font-bold. + * is the documented exception (ui-standards.md §5). Do not sweep to font-bold. */ export function StepHeading({ id, diff --git a/src/components/provenance/__tests__/source-provenance-view.test.tsx b/src/components/provenance/__tests__/source-provenance-view.test.tsx new file mode 100644 index 000000000..0ec141b7b --- /dev/null +++ b/src/components/provenance/__tests__/source-provenance-view.test.tsx @@ -0,0 +1,191 @@ +/** + * `` draws the server's answer (SSR markup): the day's + * sources with the used one marked and the reason in words, the person's + * ladder, and the side-by-side table with the used value in bold. The link + * from a metric page appears only for a metric the ladder decides for. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +const state: { day: unknown; compare: unknown } = { + day: undefined, + compare: undefined, +}; + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + ...rest + }: { + href: string; + children: React.ReactNode; + }) => ( + + {children} + + ), +})); +vi.mock("@/hooks/use-source-provenance", () => ({ + useDayProvenance: () => ({ + data: state.day, + isError: false, + error: null, + refetch: vi.fn(), + }), + useSourceComparison: () => ({ + data: state.compare, + isError: false, + error: null, + refetch: vi.fn(), + }), +})); + +import { SourceProvenanceView } from "../source-provenance-view"; +import { SourceProvenanceLink } from "../source-provenance-link"; + +function render(node: React.ReactElement) { + return renderToStaticMarkup( + {node}, + ); +} + +const DAY = { + type: "SLEEP_DURATION", + date: "2026-10-01", + aggregation: "sum", + ranked: true, + metricKey: "sleep", + ladder: ["WHOOP", "OURA"], + picked: "WHOOP", + pickReason: "highest_on_ladder", + sources: [ + { + source: "WHOOP", + rank: 0, + readings: 1, + value: 420, + min: 420, + max: 420, + firstAt: "", + lastAt: "", + unit: "min", + devices: ["band"], + pass: null, + }, + { + source: "OURA", + rank: 1, + readings: 3, + value: 391, + min: 30, + max: 301, + firstAt: "", + lastAt: "", + unit: "min", + devices: ["ring"], + pass: "lower_on_ladder", + }, + ], +}; + +const COMPARE = { + type: "SLEEP_DURATION", + from: "2026-09-02", + to: "2026-10-01", + aggregation: "sum", + ranked: true, + ladder: ["WHOOP", "OURA"], + unit: "min", + sources: [ + { source: "OURA", days: 30 }, + { source: "WHOOP", days: 20 }, + ], + a: "OURA", + b: "WHOOP", + days: [{ date: "2026-10-01", a: 391, b: 420, picked: "WHOOP" }], + summary: { + daysA: 30, + daysB: 20, + daysBoth: 20, + meanA: 395, + meanB: 440, + meanDifference: 45, + meanAbsoluteDifference: 45, + pickedA: 10, + pickedB: 20, + }, +}; + +describe("SourceProvenanceView", () => { + it("names the used source, why, and why the other was not used", () => { + state.day = DAY; + state.compare = COMPARE; + const html = render( + , + ); + expect(html).toContain('data-source="WHOOP" data-picked="true"'); + expect(html).toContain('data-source="OURA" data-picked="false"'); + expect(html).toContain("stands highest in your source priority"); + expect(html).toContain("Lower in your source priority."); + expect(html).toContain("Your order for this metric: WHOOP, Oura"); + expect(html).toContain('href="/settings/sources"'); + }); + + it("lines up two sources and marks the used value", () => { + state.day = DAY; + state.compare = COMPARE; + const html = render( + , + ); + expect(html).toContain('data-slot="provenance-compare-row"'); + expect(html).toMatch(/font-semibold tabular-nums">420/); + expect(html).toContain("10 / 20"); + }); + + it("says when there is nothing to compare", () => { + state.day = { ...DAY, sources: [], picked: null, pickReason: null }; + state.compare = { + ...COMPARE, + sources: [{ source: "OURA", days: 3 }], + b: null, + }; + const html = render( + , + ); + expect(html).toContain('data-slot="provenance-day-empty"'); + expect(html).toContain('data-slot="provenance-compare-empty"'); + }); + + it("labels the range with the charts' short day labels and spells them out", () => { + state.day = DAY; + state.compare = COMPARE; + const html = render( + , + ); + for (const days of ["30", "90", "365"]) { + expect(html).toMatch( + new RegExp( + `data-slot="provenance-range-${days}"[^>]*title="${days} days"[^>]*>${days}d<|title="${days} days"[^>]*data-slot="provenance-range-${days}"[^>]*>${days}d<`, + ), + ); + } + }); +}); + +describe("SourceProvenanceLink", () => { + it("links ranked metrics and stays away from the rest", () => { + expect(render()).toContain( + "/insights/sources?type=ACTIVITY_STEPS", + ); + expect(render()).toBe(""); + }); +}); diff --git a/src/components/provenance/source-provenance-link.tsx b/src/components/provenance/source-provenance-link.tsx new file mode 100644 index 000000000..6017748dd --- /dev/null +++ b/src/components/provenance/source-provenance-link.tsx @@ -0,0 +1,27 @@ +"use client"; + +import Link from "next/link"; + +import { InlineAction } from "@/components/ui/inline-action"; +import { useTranslations } from "@/lib/i18n/context"; +import { isProvenanceType } from "@/lib/provenance/ranked-types"; + +/** + * "Where these values come from": a small link from a metric's page to the + * provenance view, offered for the metrics the source priority decides for. + * Renders nothing for any other type. + */ +export function SourceProvenanceLink({ type }: { type: string }) { + const { t } = useTranslations(); + if (!isProvenanceType(type)) return null; + return ( + + + {t("sourceProvenance.metricLink")} + + + ); +} diff --git a/src/components/provenance/source-provenance-view.tsx b/src/components/provenance/source-provenance-view.tsx new file mode 100644 index 000000000..f3bd93d5f --- /dev/null +++ b/src/components/provenance/source-provenance-view.tsx @@ -0,0 +1,410 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { GitCompareArrows, Layers } from "lucide-react"; + +import { TileHeader } from "@/components/insights/tile-header"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { DateField } from "@/components/ui/date-field"; +import { EmptyState } from "@/components/ui/empty-state"; +import { InlineAction } from "@/components/ui/inline-action"; +import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { Segmented } from "@/components/ui/segmented"; +import { Skeleton } from "@/components/ui/skeleton"; +import { TagChip } from "@/components/ui/tag-chip"; +import { + useDayProvenance, + useSourceComparison, +} from "@/hooks/use-source-provenance"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS } from "@/lib/i18n/source-labels"; +import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys"; +import { + PROVENANCE_TYPES, + type ProvenanceType, +} from "@/lib/provenance/ranked-types"; +import { shiftDateKey } from "@/lib/tz/format"; + +type RangeDays = "30" | "90" | "365"; + +/** + * Today as a UTC day key: the source ladder picks per UTC day, so the page + * names its days the way the pick cuts them. + */ +function utcToday(): string { + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the ladder pick is per UTC day, and this page explains that pick + return new Date().toISOString().slice(0, 10); +} + +/** + * The provenance view (#1204, #1108, #576): for one metric, which sources + * delivered values on a day, which one the source priority picked and why, + * and two sources side by side over a range. Read-only; the settings that + * decide the pick stay in Settings > Source priority. + */ +export function SourceProvenanceView({ + initialType, + initialDate, +}: { + initialType: ProvenanceType; + initialDate: string | null; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const [type, setType] = useState(initialType); + const [date, setDate] = useState(initialDate ?? utcToday()); + const [range, setRange] = useState("30"); + const [a, setA] = useState(undefined); + const [b, setB] = useState(undefined); + const to = utcToday(); + const from = shiftDateKey(to, -(Number(range) - 1)); + + const day = useDayProvenance(type, date); + const compare = useSourceComparison({ type, from, to, a, b }); + + const sourceLabel = (source: string) => { + const key = + MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS[ + source as keyof typeof MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS + ]; + return key ? t(key) : source; + }; + const typeLabel = (value: ProvenanceType) => + t(MEASUREMENT_TYPE_LABEL_KEYS[value]); + const sortedTypes = [...PROVENANCE_TYPES].sort((x, y) => + typeLabel(x).localeCompare(typeLabel(y)), + ); + + const formatValue = (value: number | null, aggregation: "sum" | "mean") => + value === null + ? "–" + : aggregation === "sum" + ? fmt.integer(Math.round(value)) + : fmt.number(value, 1); + + return ( +

+
+
+ + { + setType(e.target.value as ProvenanceType); + setA(undefined); + setB(undefined); + }} + > + {sortedTypes.map((value) => ( + + ))} + +
+
+ + value && setDate(value)} + /> +
+
+ + + + + + + {day.isError ? ( + day.refetch()} + /> + ) : !day.data ? ( + + ) : day.data.sources.length === 0 ? ( + + ) : ( + <> +

+ {day.data.pickReason === "not_ranked" + ? t("sourceProvenance.reason.not_ranked") + : t( + `sourceProvenance.reason.${day.data.pickReason ?? "only_source"}`, + { + source: sourceLabel(day.data.picked ?? ""), + }, + )} +

+
    + {day.data.sources.map((row) => ( +
  • +
    +

    + {sourceLabel(row.source)} + {row.source === day.data?.picked ? ( + {t("sourceProvenance.used")} + ) : null} +

    +

    + {t("sourceProvenance.readings", { + count: fmt.integer(row.readings), + devices: row.devices.join(", "), + })} +

    + {row.pass ? ( +

    + {t(`sourceProvenance.pass.${row.pass}`)} +

    + ) : null} +
    +

    + {formatValue(row.value, day.data?.aggregation ?? "mean")}{" "} + + {row.unit} + +

    +
  • + ))} +
+ {day.data.ranked ? ( +

+ {t("sourceProvenance.ladder", { + ladder: day.data.ladder.map(sourceLabel).join(", "), + })} +

+ ) : null} +

+ {t("sourceProvenance.utcNote")} +

+ + )} + + + {t("sourceProvenance.openSettings")} + + +
+
+ + + + ({ + value: days, + label: t("charts.daysNLabel", { days }), + title: t("sourceProvenance.rangeDays", { days }), + }))} + /> + } + /> + + + {compare.isError ? ( + compare.refetch()} + /> + ) : !compare.data ? ( + + ) : compare.data.sources.length < 2 ? ( + + ) : ( + <> +
+ {(["a", "b"] as const).map((side) => ( +
+ + + side === "a" + ? setA(e.target.value) + : setB(e.target.value) + } + > + {compare.data?.sources.map((s) => ( + + ))} + +
+ ))} +
+
+
+ {t("sourceProvenance.daysBoth")} +
+
+ {fmt.integer(compare.data.summary.daysBoth)} +
+
+ {t("sourceProvenance.meanOf", { + source: sourceLabel(compare.data.a ?? ""), + })} +
+
+ {formatValue( + compare.data.summary.meanA, + compare.data.aggregation, + )} +
+
+ {t("sourceProvenance.meanOf", { + source: sourceLabel(compare.data.b ?? ""), + })} +
+
+ {formatValue( + compare.data.summary.meanB, + compare.data.aggregation, + )} +
+
+ {t("sourceProvenance.meanAbsDiff")} +
+
+ {formatValue( + compare.data.summary.meanAbsoluteDifference, + "mean", + )}{" "} + + {compare.data.unit} + +
+
+ {t("sourceProvenance.pickedDays")} +
+
+ {t("sourceProvenance.pickedSplit", { + a: fmt.integer(compare.data.summary.pickedA), + b: fmt.integer(compare.data.summary.pickedB), + })} +
+
+
+ + + + + + + + + + {[...compare.data.days].reverse().map((d) => ( + + + {(["a", "b"] as const).map((side) => { + const source = compare.data?.[side]; + const used = source !== null && d.picked === source; + return ( + + ); + })} + + ))} + +
+ {t("sourceProvenance.day")} + + {sourceLabel(compare.data.a ?? "")} + + {sourceLabel(compare.data.b ?? "")} +
+ setDate(d.date)} + > + {fmt.dateShortSmartCalendar(d.date)} + + + {formatValue( + d[side], + compare.data?.aggregation ?? "mean", + )} + {used ? ( + + {" "} + {t("sourceProvenance.used")} + + ) : null} +
+
+

+ {t("sourceProvenance.compareNote")} +

+ + )} +
+
+
+ ); +} diff --git a/src/components/records/screening-hints-card.tsx b/src/components/records/screening-hints-card.tsx index 76b7859c1..fa76cec15 100644 --- a/src/components/records/screening-hints-card.tsx +++ b/src/components/records/screening-hints-card.tsx @@ -2,10 +2,11 @@ /** * v1.43 (#614) — screening hints from the life circumstances, in Settings → - * Vorsorge. Low-dose CT for lung cancer and the abdominal-aorta ultrasound, - * each with its status, the reasons in the account's language and the rule's - * source and country. A hint, never a reminder: nothing is scheduled here, - * and the card says so. Resolved on the server; rendered as delivered. + * Health profile → Medical history (under Checkups until v1.44). Low-dose CT + * for lung cancer and the abdominal-aorta ultrasound, each with its status, + * the reasons in the account's language and the rule's source and country. + * A hint, never a reminder: nothing is scheduled here, and the card says so. + * Resolved on the server; rendered as delivered. * * The card stays away until it has something to say: the person has said * something about smoking, or a hint already applies without it (the German diff --git a/src/components/settings/__tests__/ai-section.test.tsx b/src/components/settings/__tests__/ai-section.test.tsx index b647fc056..1d4df55bf 100644 --- a/src/components/settings/__tests__/ai-section.test.tsx +++ b/src/components/settings/__tests__/ai-section.test.tsx @@ -218,7 +218,7 @@ describe(" — dropdown-driven UX (B2)", () => { // both ship without the "KI"/"AI" prefix per the project-voice rule // ("Aktiver KI-Provider" → "Aktiver Provider"). The picker below it is // labelled for what it does: it chooses the form, not the order. - expect(html).toContain("Aktiver Provider"); + expect(html).toContain("Aktiver Anbieter"); expect(html).toContain("Anbieter einrichten"); }); @@ -243,6 +243,6 @@ describe(" — dropdown-driven UX (B2)", () => { it("no longer mounts the Coach cards (moved to the Coach section)", () => { activeProviderQuery = "codex"; const html = render(); - expect(html).not.toContain('data-testid="settings-disable-coach-card"'); + expect(html).not.toContain('data-slot="settings-coach-state-card"'); }); }); diff --git a/src/components/settings/__tests__/anamnesis-about-me-relocation.test.tsx b/src/components/settings/__tests__/anamnesis-about-me-relocation.test.tsx index 40ae5dc22..0383d285b 100644 --- a/src/components/settings/__tests__/anamnesis-about-me-relocation.test.tsx +++ b/src/components/settings/__tests__/anamnesis-about-me-relocation.test.tsx @@ -35,6 +35,9 @@ vi.mock("@/components/records/ai-profile-inclusion-manager", () => ({ vi.mock("@/components/records/conditions-manager", () => ({ ConditionsManager: () => null, })); +vi.mock("@/components/records/screening-hints-card", () => ({ + ScreeningHintsCard: () => null, +})); vi.mock("@/components/records/life-circumstances-manager", () => ({ LifeCircumstancesManager: () => null, })); diff --git a/src/components/settings/__tests__/anamnesis-ai-inclusion-gate.test.tsx b/src/components/settings/__tests__/anamnesis-ai-inclusion-gate.test.tsx index 83b4a1e14..2db363219 100644 --- a/src/components/settings/__tests__/anamnesis-ai-inclusion-gate.test.tsx +++ b/src/components/settings/__tests__/anamnesis-ai-inclusion-gate.test.tsx @@ -18,6 +18,9 @@ vi.mock("@/components/records/ai-profile-inclusion-manager", () => ({ vi.mock("@/components/records/conditions-manager", () => ({ ConditionsManager: () =>
, })); +vi.mock("@/components/records/screening-hints-card", () => ({ + ScreeningHintsCard: () => null, +})); vi.mock("@/components/records/life-circumstances-manager", () => ({ LifeCircumstancesManager: () => null, })); diff --git a/src/components/settings/__tests__/coach-memory-section.test.tsx b/src/components/settings/__tests__/coach-memory-section.test.tsx index d93d4b891..aaf49c186 100644 --- a/src/components/settings/__tests__/coach-memory-section.test.tsx +++ b/src/components/settings/__tests__/coach-memory-section.test.tsx @@ -12,8 +12,7 @@ * The mutation wiring itself (DELETE `/facts/{id}` and DELETE `/facts` * → `queryKeys.coachFacts()` invalidation + toast) is pinned by the * route-shape contract in the component plus the typecheck gate; this - * suite guards the render contract the way the sibling - * `disable-coach-card.test.tsx` does for the toggle. + * suite guards the render contract. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; diff --git a/src/components/settings/__tests__/coach-section.test.tsx b/src/components/settings/__tests__/coach-section.test.tsx index d7811ef97..101f834fa 100644 --- a/src/components/settings/__tests__/coach-section.test.tsx +++ b/src/components/settings/__tests__/coach-section.test.tsx @@ -1,10 +1,10 @@ /** * v1.18.0 — Settings → Coach section. * - * The Coach preference cards (disable toggle, preferences, memory) moved - * out of the AI / Assistant section into their own dedicated, module-gated - * entry. SSR-only smoke test — interactive contracts live in the cards' - * own suites (`disable-coach-card.test.tsx`, `coach-memory-section.test.tsx`). + * The Coach preference cards (state, preferences, memory) moved out of the + * AI / Assistant section into their own dedicated, module-gated entry. + * SSR-only smoke test — interactive contracts live in the cards' own suites + * (`coach-memory-section.test.tsx`; the switch in `modules-section.test.tsx`). */ import { describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; @@ -78,34 +78,51 @@ function render(locale: "en" | "de" = "en") { } describe(" — SSR smoke", () => { - it("renders the section scaffold + the Activate-Coach toggle card", () => { + it("shows the Coach state with a link to the one switch, not a second switch", () => { authState.disableCoach = false; const html = render(); expect(html).toContain('id="settings-section-coach-title"'); - expect(html).toContain('data-testid="settings-disable-coach-card"'); - expect(html).toContain('data-testid="settings-disable-coach-switch"'); - // v1.18.1 — polarity flipped to activate/default-on. - expect(html).toContain("Activate Coach"); - expect(html).toContain("Show the Coach button and drawer"); - // v1.18.6 — the proactive Coach nudge card moved here from - // Benachrichtigungen; its anchor renders under the enabled-coach gate. - expect(html).toContain('id="coach-nudge"'); + expect(html).toContain('data-slot="settings-coach-state-card"'); + expect(html).toContain('data-enabled="true"'); + expect(html).toContain('href="/settings/modules#module-coach"'); + // v1.44 — no switch of its own: one state, one control, in Modules. + const card = html.match( + /data-slot="settings-coach-state-card"[\s\S]*?<\/a>/, + )?.[0]; + expect(card).toBeDefined(); + expect(card).not.toContain('role="switch"'); + expect(html).not.toContain("settings-disable-coach"); // Raw key never leaks past i18n. expect(html).not.toContain("settings.sections.coach."); - expect(html).not.toContain("settings.coach.activate."); + expect(html).not.toContain("settings.coach.state."); }); - it("renders the German Activate-Coach copy", () => { - authState.disableCoach = false; + it("reads off once the account has the Coach off", () => { + mountState.mounted = true; + authState.disableCoach = true; + try { + expect(render()).toContain('data-enabled="false"'); + } finally { + mountState.mounted = false; + authState.disableCoach = false; + } + }); + + it("points the nudges at Settings > Notifications", () => { + const html = render(); + expect(html).toContain('href="/settings/notifications#coach-nudge"'); + expect(html).not.toContain('id="coach-nudge"'); + }); + + it("renders the German copy", () => { const html = render("de"); - expect(html).toContain("Coach aktivieren"); expect(html).not.toContain("settings.sections.coach."); - expect(html).not.toContain("settings.coach.activate."); + expect(html).not.toContain("settings.coach.state."); }); }); describe(" — the tuning follows the record, not the account", () => { - it("hides the nudge when the record has the Coach off, whatever the viewer's own opt-out says", () => { + it("hides the nudge pointer when the record has the Coach off, whatever the viewer's own opt-out says", () => { // A guardian with the Coach on, inside a managed record that has it off: // `/me` publishes the viewer's own `disableCoach` and the RECORD's module // map. The record's answer decides. @@ -113,10 +130,10 @@ describe(" — the tuning follows the record, not the account", () authState.disableCoach = false; authState.modules = { coach: false }; try { - expect(render()).not.toContain('id="coach-nudge"'); + expect(render()).not.toContain('data-slot="settings-coach-nudge-link"'); authState.disableCoach = true; authState.modules = { coach: true }; - expect(render()).toContain('id="coach-nudge"'); + expect(render()).toContain('data-slot="settings-coach-nudge-link"'); } finally { mountState.mounted = false; authState.disableCoach = false; diff --git a/src/components/settings/__tests__/confirm-style-guard.test.ts b/src/components/settings/__tests__/confirm-style-guard.test.ts new file mode 100644 index 000000000..b78389e39 --- /dev/null +++ b/src/components/settings/__tests__/confirm-style-guard.test.ts @@ -0,0 +1,75 @@ +/** + * One confirm style across Settings. + * + * A destructive act (delete, revoke, remove, reset, sign out) confirms through + * `ConfirmButton` / `ConfirmDialog` (`src/components/ui/confirm-button.tsx`), + * which owns the dialog's anatomy and wears the destructive style when the + * caller says the act is destructive. A hand-built `AlertDialog` is how the + * same question ended up styled three different ways on neighbouring cards. + * + * So no file under `src/components/settings` imports the raw `AlertDialog` + * unless it is listed below with the reason the primitive does not fit. + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const ROOT = join(process.cwd(), "src", "components", "settings"); + +const ALLOWED: Readonly> = { + "security-section/existing-factor-reauth-dialog.tsx": + "a re-authentication form, not a destructive confirm", + "security-section/passkey-list-section.tsx": + "the add-passkey re-authentication form; the delete confirm uses ConfirmButton", + "security-section/totp-card.tsx": + "the disable confirm asks for a current code and stays open until the server answers; both actions wear the destructive style", +}; + +/** Owned by the integration cards, which follow their own card kit. */ +const ALLOWED_PREFIXES: readonly string[] = ["integrations/"]; + +function walk(dir: string): string[] { + return readdirSync(dir).flatMap((name) => { + const path = join(dir, name); + if (statSync(path).isDirectory()) { + return name === "__tests__" ? [] : walk(path); + } + return /\.tsx?$/.test(name) ? [path] : []; + }); +} + +const IMPORTS_ALERT_DIALOG = /from\s+["']@\/components\/ui\/alert-dialog["']/; + +describe("settings confirm style", () => { + const files = walk(ROOT); + + it("scans the settings tree", () => { + expect(files.length).toBeGreaterThan(50); + }); + + it("imports the raw AlertDialog only where the primitive does not fit", () => { + const offenders = files + .map((path) => relative(ROOT, path)) + .filter((rel) => + IMPORTS_ALERT_DIALOG.test(readFileSync(join(ROOT, rel), "utf8")), + ) + .filter( + (rel) => + !(rel in ALLOWED) && + !ALLOWED_PREFIXES.some((prefix) => rel.startsWith(prefix)), + ); + expect( + offenders, + "Confirm a destructive act through ConfirmButton / ConfirmDialog, or list the file here with the reason", + ).toEqual([]); + }); + + it("lists no file that no longer needs the exemption", () => { + const stale = Object.keys(ALLOWED).filter( + (rel) => + !IMPORTS_ALERT_DIALOG.test(readFileSync(join(ROOT, rel), "utf8")), + ); + expect(stale).toEqual([]); + }); +}); diff --git a/src/components/settings/__tests__/disable-coach-card.test.tsx b/src/components/settings/__tests__/disable-coach-card.test.tsx deleted file mode 100644 index 87acdf78b..000000000 --- a/src/components/settings/__tests__/disable-coach-card.test.tsx +++ /dev/null @@ -1,155 +0,0 @@ -/** - * v1.4.47 — Settings → Coach activation toggle persistence contract. - * - * v1.18.1 — the card reads "Activate Coach" (default ON). The persisted - * column is still `User.disableCoach`; the Switch shows the activated view - * (`checked = !disableCoach`). The SSR render is `mounted=false`, where the - * card paints the default-ON (checked) state so the default-on contract holds - * before the wire value resolves — both default and opted-out users render - * checked under `renderToStaticMarkup`. - * - * The card is a small client-only `` that PATCHes - * `/api/auth/me/disable-coach` and invalidates `queryKeys.authMe()` so - * every Coach mount point in the app reacts on the next /me refetch. - * - * Test strategy: use the real `useMutation` + `QueryClient` from - * `@tanstack/react-query`, stub `fetch`, and drive the Switch via a - * `renderToStaticMarkup` pass plus a hand-invoked mutation call. The - * file SSR-renders inside a `` so the hook - * branches execute and the spies fire. - */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -import { I18nProvider } from "@/lib/i18n/context"; -import type { AuthUser } from "@/hooks/use-auth"; - -const authSpy = vi.fn< - () => { user: AuthUser | null; isAuthenticated: boolean } ->(() => ({ user: buildUser(false), isAuthenticated: true })); -vi.mock("@/hooks/use-auth", async () => { - const actual = - await vi.importActual( - "@/hooks/use-auth", - ); - return { - ...actual, - useAuth: () => authSpy(), - }; -}); - -// Stub the navigation hooks the parent `` (and its child -// cards) reach for so the SSR pass doesn't blow up. The disable-coach card -// itself doesn't trigger any GET — it only writes through PATCH on click. -// -// v1.18.0 — the Coach cards moved from `` to the dedicated -// ``; this contract test renders the new parent. -vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), - usePathname: () => "/settings/coach", - useSearchParams: () => new URLSearchParams(""), -})); - -function buildUser(disableCoach: boolean): AuthUser { - return { - id: "user-1", - username: "testuser", - email: null, - role: "USER", - heightCm: null, - dateOfBirth: null, - gender: null, - timezone: "Europe/Berlin", - onboardingCompletedAt: null, - onboardingTourCompleted: true, - onboardingTourProgress: null, - avatarUrl: null, - glucoseUnit: null, - unitPreference: "metric", - timeFormat: "AUTO", - dateFormat: "AUTO", - disableCoach, - fullName: null, - insurerName: null, - insurerIkNumber: null, - insuranceNumber: null, - lastReportPracticeName: null, - reportSelection: null, - cycleTrackingEnabled: false, - modules: {}, - }; -} - -import { CoachSection } from "../coach-section"; - -function makeFetch() { - return vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { - const body = init?.body ? JSON.parse(init.body as string) : {}; - return new Response( - JSON.stringify({ - data: { disableCoach: body.disableCoach ?? false }, - }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ); - }); -} - -beforeEach(() => { - authSpy.mockClear(); - authSpy.mockImplementation(() => ({ - user: buildUser(false), - isAuthenticated: true, - })); - vi.stubGlobal("fetch", makeFetch()); -}); - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -function render(): string { - const client = new QueryClient({ - defaultOptions: { queries: { retry: 0 } }, - }); - return renderToStaticMarkup( - - - - - , - ); -} - -describe("Settings — DisableCoachCard", () => { - it("renders the activation Switch checked at SSR (default-on contract)", () => { - const html = render(); - // v1.18.1 — the SSR render is `mounted=false`, where the card paints - // the default-ON (activated → checked) state regardless of the wire value, - // so the Switch never flashes off before /me resolves. - expect(html).toContain('data-testid="settings-disable-coach-switch"'); - expect(html).toContain('data-state="checked"'); - }); - - it("paints the activated (checked) SSR state even for an opted-out user", () => { - authSpy.mockImplementation(() => ({ - user: buildUser(true), - isAuthenticated: true, - })); - const html = render(); - expect(html).toContain('data-testid="settings-disable-coach-switch"'); - // The activated state resolves on the first client re-render once - // `mounted` flips; the SSR pass stays on the default-ON shape. - expect(html).toContain('data-state="checked"'); - }); - - it("renders the Coach-activation title + description", () => { - const html = render(); - // v1.18.1 — polarity flipped to activate/default-on. - expect(html).toContain("Activate Coach"); - expect(html).toContain("Show the Coach button and drawer"); - }); -}); diff --git a/src/components/settings/__tests__/layout-groups.test.ts b/src/components/settings/__tests__/layout-groups.test.ts index b028df7dc..cb8e2b113 100644 --- a/src/components/settings/__tests__/layout-groups.test.ts +++ b/src/components/settings/__tests__/layout-groups.test.ts @@ -16,7 +16,6 @@ import { describe, expect, it, vi } from "vitest"; vi.mock("../dashboard-section", () => ({ DashboardSection: () => null })); vi.mock("../insights-section", () => ({ InsightsSection: () => null })); vi.mock("../medications-section", () => ({ MedicationsSection: () => null })); -vi.mock("../mood-section", () => ({ MoodSection: () => null })); vi.mock("../labs-section", () => ({ LabsSection: () => null })); vi.mock("../illness-section", () => ({ IllnessSection: () => null })); vi.mock("../vorsorge-section", () => ({ VorsorgeSection: () => null })); @@ -29,12 +28,13 @@ import { } from "../layout-groups"; describe("layout-groups registry", () => { - it("exposes the eight Appearance modules as statically-generated subpage params", () => { + it("exposes the seven Arrangement modules as statically-generated subpage params", () => { + // v1.44 — mood left: its page was tag management, which lives under + // Settings > Modules > Mood now (`/settings/layout/mood` redirects). expect(LAYOUT_GROUP_IDS).toEqual([ "dashboard", "insights", "medications", - "mood", "labs", "illness", "documents", @@ -45,7 +45,6 @@ describe("layout-groups registry", () => { { module: "dashboard" }, { module: "insights" }, { module: "medications" }, - { module: "mood" }, { module: "labs" }, { module: "illness" }, { module: "documents" }, @@ -66,7 +65,7 @@ describe("layout-groups registry", () => { ? layoutGroupModule(id) : "not-a-group"; expect(gate("medications")).toBe("medications"); - expect(gate("mood")).toBe("mood"); + expect(gate("mood")).toBe("not-a-group"); expect(gate("labs")).toBe("labs"); expect(gate("illness")).toBe("illness"); // dashboard / insights / vorsorge are never gated. diff --git a/src/components/settings/__tests__/layout-section.test.tsx b/src/components/settings/__tests__/layout-section.test.tsx index 5b4935d64..97d29dadd 100644 --- a/src/components/settings/__tests__/layout-section.test.tsx +++ b/src/components/settings/__tests__/layout-section.test.tsx @@ -54,7 +54,6 @@ describe(" hub", () => { "dashboard", "insights", "medications", - "mood", "labs", "illness", "vorsorge", @@ -68,7 +67,7 @@ describe(" hub", () => { expect(html).not.toContain('href="/settings/layout/medications"'); expect(html).not.toContain('href="/settings/layout/labs"'); // Other modules unaffected; Vorsorge is never gated. - expect(html).toContain('href="/settings/layout/mood"'); + expect(html).toContain('href="/settings/layout/illness"'); expect(html).toContain('href="/settings/layout/vorsorge"'); }); @@ -82,6 +81,7 @@ describe(" hub", () => { expect(html).toContain('href="/settings/layout/dashboard"'); expect(html).toContain('href="/settings/layout/insights"'); expect(html).toContain('href="/settings/layout/vorsorge"'); + // v1.44 — mood is tag management and left Arrangement for Modules. expect(html).not.toContain('href="/settings/layout/mood"'); }); diff --git a/src/components/settings/__tests__/managed-integration-status.test.tsx b/src/components/settings/__tests__/managed-integration-status.test.tsx index 8cc091ea6..d7126b5dd 100644 --- a/src/components/settings/__tests__/managed-integration-status.test.tsx +++ b/src/components/settings/__tests__/managed-integration-status.test.tsx @@ -1,22 +1,25 @@ /** - * A managed record's connections on the shared integration card, read-only. + * A managed record's connections on the shared integration card: a guardian + * connects and disconnects the record's OAuth providers, the rest is status. */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import type { ConnectionHealth } from "@/lib/integrations/connection-health"; +const NAMED_RECORD = { + accountId: "record-1", + recordKind: "managed", + username: "managed-1", + displayName: "Managed record" as string | null, + fullName: null as string | null, +}; +let activeRecord = NAMED_RECORD; vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ user: { id: "guardian", - accountAccess: { - active: { - accountId: "record-1", - recordKind: "managed", - displayName: "Managed record", - }, - }, + accountAccess: { active: activeRecord }, }, isAuthenticated: true, isLoading: false, @@ -32,6 +35,8 @@ vi.mock("@tanstack/react-query", () => ({ isError: false, refetch: vi.fn(), }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), })); import { I18nProvider } from "@/lib/i18n/context"; @@ -75,9 +80,10 @@ describe("", () => { beforeEach(() => { payload = undefined; loading = false; + activeRecord = NAMED_RECORD; }); - it("renders one shared card per provider from health, with no control on any", () => { + it("renders one shared card per provider from health, with controls on the OAuth ones", () => { payload = withHealth({ oura: healthFixture({ state: "action_required", @@ -100,26 +106,53 @@ describe("", () => { expect(html).toMatch( /data-testid="managed-integration-oura"[\s\S]*?data-slot="integration-health-badge"[^>]*data-state="action_required"/, ); - // Read-only even where the state asks for an action. - expect(html).not.toContain('data-slot="integration-primary-action"'); - expect(html).not.toContain('data-slot="integration-menu-trigger"'); + // The reconnect a broken grant asks for, on the card that asks for it. + expect(html).toContain('data-testid="oura-reconnect-button"'); + // A connected OAuth card carries the disconnect menu, and every OAuth card + // says whose record it connects for. + expect(html).toContain('data-testid="withings-menu"'); + expect(html.match(/Connects for Managed record/g)).toHaveLength(6); + // Each unconnected OAuth provider offers one connect for the record; + // Fitbit (retired) and Nightscout (no OAuth) stay status only. + for (const key of ["whoop", "polar", "google-health", "strava"]) { + expect(html).toContain( + `data-testid="managed-integration-${key}-connect"`, + ); + } + expect(html).not.toContain( + 'data-testid="managed-integration-fitbit-connect"', + ); + expect(html).not.toContain( + 'data-testid="managed-integration-nightscout-connect"', + ); + expect(html.match(/>Connect for Managed recordNot connected\. { + it("says once where connected data goes, and links the explanation", () => { payload = withHealth({}); const html = render(); - expect(html).toContain('data-slot="managed-integration-read-only"'); + expect(html).toContain('data-slot="managed-integration-target"'); expect(html).toContain( - "A managed profile shows its connected services as a status only.", + "Services you connect here send their data to Managed record's record, not to yours.", ); expect(html).toContain( 'href="https://docs.healthlog.dev/self-hosting/account-sharing#records-with-no-login"', ); }); + it("names a profile without a display name by its username", () => { + activeRecord = { ...NAMED_RECORD, displayName: null }; + payload = withHealth({}); + const html = render(); + expect(html.match(/Connects for managed-1Connect for managed-1 { loading = true; const html = render(); diff --git a/src/components/settings/__tests__/medications-section.test.tsx b/src/components/settings/__tests__/medications-section.test.tsx index 65e5e7db5..141437220 100644 --- a/src/components/settings/__tests__/medications-section.test.tsx +++ b/src/components/settings/__tests__/medications-section.test.tsx @@ -137,11 +137,11 @@ describe(" — SSR smoke", () => { expect(lastActive).toBeLessThan(html.indexOf("Amoxicillin")); }); - it("gathers the injection-sites card (moved from the account profile)", () => { - // v1.18.0 — injection-site exclusions are a medication setting and - // now live on this screen alongside the list view + order. + it("holds order and view only; the injection sites live under Modules", () => { + // v1.44 — Arrangement holds order and view; the injection-site + // exclusions moved to Settings > Modules > Medications. const html = render(); - expect(html).toContain("Globally excluded injection sites"); + expect(html).not.toContain("Globally excluded injection sites"); }); it("feeds the editor the saved order (active block re-sorted by layout.order)", () => { diff --git a/src/components/settings/__tests__/mood-section.test.tsx b/src/components/settings/__tests__/mood-section.test.tsx index 2735bb59c..2d2fbbb76 100644 --- a/src/components/settings/__tests__/mood-section.test.tsx +++ b/src/components/settings/__tests__/mood-section.test.tsx @@ -131,7 +131,6 @@ vi.mock("@/hooks/use-auth", () => ({ })); import { I18nProvider } from "@/lib/i18n/context"; -import { SettingsSectionFrame } from "./section-frame-harness"; import { MoodSection } from "../mood-section"; import { primeLocales } from "@/__tests__/helpers/prime-locales"; @@ -142,9 +141,7 @@ function render(locale: "en" | "de" = "en"): string { // shared frame the route wraps the section in. return renderToStaticMarkup( - - - + , ); } @@ -159,7 +156,8 @@ describe(" — SSR smoke", () => { // v1.18.1 — the section blurb was dropped; the sr-only heading + the // card content carry the meaning. const html = render(); - expect(html).toContain("settings-section-mood-title"); + // v1.44 — the page lives under Settings > Modules > Mood; its heading + // comes from the route, not from a frame here. expect(html).not.toContain("settings.sections."); expect(html).not.toContain("mood.manage."); }); diff --git a/src/components/settings/__tests__/notification-channel-cards.test.tsx b/src/components/settings/__tests__/notification-channel-cards.test.tsx index 85861ac18..0e09da9cf 100644 --- a/src/components/settings/__tests__/notification-channel-cards.test.tsx +++ b/src/components/settings/__tests__/notification-channel-cards.test.tsx @@ -121,17 +121,23 @@ describe("notification channel cards", () => { ); it.each([ - ["webhook", , 1], - ["ntfy", , 1], - ["email", , 1], - ["telegram", , 0], + ["webhook", ], + ["ntfy", ], + ["email", ], + ["telegram", ], ] as const)( - "%s switch invokes the enable-only mutation rather than the full save", - async (channel, card, toggleMutationIndex) => { - render(card); - - await mocks.mutationOptions[toggleMutationIndex]!.mutationFn(true); + "%s enable switch saves on tap, alone, outside the form", + async (channel, card) => { + const html = render(card); + // The switch precedes the form: its Save covers the fields, not the + // switch. + expect(html.indexOf('role="switch"')).toBeGreaterThan(-1); + expect(html.indexOf('role="switch"')).toBeLessThan(html.indexOf(" { ); it.each([ - [, 1, ["settings", "webhook"]], - [, 1, ["settings", "ntfy"]], - [, 1, ["settings", "email"]], - [ - , - 0, - ["telegram", "settings"], - ], + [, ["settings", "webhook"]], + [, ["settings", "ntfy"]], + [, ["settings", "email"]], + [, ["telegram", "settings"]], ] as const)( - "rolls each visible switch back to its previous server state on error", - async (card, toggleMutationIndex, queryKey) => { + "rolls each switch back to its previous server state on error", + async (card, queryKey) => { const previous = { enabled: false, persistedConfig: "unchanged" }; mocks.queryClient.getQueryData.mockReturnValue(previous); render(card); - const toggle = mocks.mutationOptions[toggleMutationIndex]!; + const toggle = mocks.mutationOptions[0]!; const context = await toggle.onMutate!(true); const optimisticUpdater = mocks.queryClient.setQueryData.mock @@ -170,7 +172,6 @@ describe("notification channel cards", () => { }); toggle.onError!(new Error("save failed"), true, context); - expect(mocks.queryClient.setQueryData).toHaveBeenLastCalledWith( queryKey, previous, @@ -185,9 +186,9 @@ describe("notification channel cards", () => { expect(html).toMatch(/
); diff --git a/src/components/settings/import-panel/index.tsx b/src/components/settings/import-panel/index.tsx index 7f6ebde9e..9f39cf527 100644 --- a/src/components/settings/import-panel/index.tsx +++ b/src/components/settings/import-panel/index.tsx @@ -14,6 +14,9 @@ * (file/paste, client-side parseability guard). * 3. CSV import — `csv-import-card.tsx` * (file/paste, dry-run preview). + * 4. Lab results and medications from a spreadsheet — + * `record-csv-import-card.tsx` (v1.44, #1215; column mapping, check, + * import). * * Both controls surface rate-limit (429) and error states cleanly and * announce progress to assistive tech via `aria-live` regions. @@ -28,6 +31,7 @@ import { HealthConnectImportCard } from "./health-connect-import-card"; import { JsonImportCard } from "./json-import-card"; import { CsvImportCard } from "./csv-import-card"; import { DoseHistoryImportCard } from "./dose-history-import-card"; +import { RecordCsvImportCard } from "./record-csv-import-card"; export { EXAMPLE_IMPORT, @@ -55,6 +59,7 @@ export function ImportPanel() { +
); diff --git a/src/components/settings/import-panel/record-csv-import-card.tsx b/src/components/settings/import-panel/record-csv-import-card.tsx new file mode 100644 index 000000000..2613f2b0a --- /dev/null +++ b/src/components/settings/import-panel/record-csv-import-card.tsx @@ -0,0 +1,562 @@ +"use client"; + +import { useId, useMemo, useState } from "react"; +import { Download, FileSpreadsheet, FlaskConical, Pill } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { Switch } from "@/components/ui/switch"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { apiFetch, ApiError } from "@/lib/api/api-fetch"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { + RECORD_IMPORT_TEMPLATES, + fieldsFor, + guessFormat, + guessMapping, + readHead, + type FieldKey, + type FileHead, + type RecordImportKind, +} from "@/lib/import/csv-mapping"; +import type { RecordImportResult } from "@/lib/validations/record-import"; + +import { ImportCardShell } from "./import-card-shell"; + +/** The head of the file the mapping screen reads: enough for six rows. */ +const HEAD_BYTES = 64 * 1024; +const MAX_FILE_BYTES = 16 * 1024 * 1024; + +type Delimiter = "comma" | "semicolon" | "tab"; + +function delimiterName(d: string): Delimiter { + return d === ";" ? "semicolon" : d === "\t" ? "tab" : "comma"; +} + +function downloadTemplate(kind: RecordImportKind) { + const blob = new Blob([RECORD_IMPORT_TEMPLATES[kind]], { type: "text/csv" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `healthlog-${kind}-template.csv`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + +/** + * Settings > Export & Import: lab results and medications from a spreadsheet + * (#1215). The card opens a sheet that reads the head of the file, suggests + * the column for each field, lets the person correct every choice, checks + * the whole file on the server (nothing written) and then imports it. + */ +export function RecordCsvImportCard() { + const { t } = useTranslations(); + const [open, setOpen] = useState(null); + const [session, setSession] = useState(0); + return ( + +

{t("recordImport.detail")}

+ + {(["labs", "medications"] as const).map((kind) => ( + + ))} + + {open ? ( + !next && setOpen(null)} + /> + ) : null} +
+ ); +} + +function RecordCsvImportSheet({ + kind, + onOpenChange, +}: { + kind: RecordImportKind; + onOpenChange: (open: boolean) => void; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const fileId = useId(); + const [file, setFile] = useState(null); + const [head, setHead] = useState(null); + const [mapping, setMapping] = useState>>({}); + const [delimiter, setDelimiter] = useState("comma"); + const [decimal, setDecimal] = useState<"dot" | "comma">("dot"); + const [dateOrder, setDateOrder] = useState<"iso" | "dmy" | "mdy">("iso"); + const [header, setHeader] = useState(true); + const [reminders, setReminders] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [check, setCheck] = useState(null); + const [done, setDone] = useState(null); + + const fields = fieldsFor(kind); + const columns = useMemo(() => { + if (!head) return [] as string[]; + const width = Math.max(...head.rows.map((r) => r.length), 0); + return Array.from({ length: width }, (_, i) => + header && head.rows[0]?.[i]?.trim() + ? head.rows[0][i].trim() + : t("recordImport.column", { n: i + 1 }), + ); + }, [head, header, t]); + const missing = fields.filter( + (f) => f.required && mapping[f.key] === undefined, + ); + + async function onFile(e: React.ChangeEvent) { + const picked = e.target.files?.[0] ?? null; + e.target.value = ""; + setCheck(null); + setDone(null); + setError(null); + if (!picked) return; + if (picked.size > MAX_FILE_BYTES) { + setError(t("recordImport.error.tooLarge")); + return; + } + const text = await picked.slice(0, HEAD_BYTES).text(); + const parsedHead = readHead(text); + const guessed = guessMapping(parsedHead.rows[0] ?? [], kind); + const format = guessFormat( + parsedHead, + guessed[kind === "labs" ? "date" : "startsOn"], + ); + setFile(picked); + setHead(parsedHead); + setMapping(guessed); + setDelimiter(delimiterName(parsedHead.delimiter)); + setDecimal(format.decimal); + setDateOrder(format.dateOrder); + } + + async function send(dryRun: boolean) { + if (!file) return; + setBusy(true); + setError(null); + const params = new URLSearchParams({ + dryRun: dryRun ? "1" : "0", + header: header ? "1" : "0", + delimiter, + decimal, + dateOrder, + }); + if (kind === "medications") params.set("reminders", reminders ? "1" : "0"); + for (const f of fields) { + const index = mapping[f.key]; + if (index !== undefined) params.set(f.key, String(index)); + } + try { + const result = await apiFetch( + `/api/import/${kind}-csv?${params}`, + { + method: "POST", + headers: { "Content-Type": "text/csv" }, + body: file, + // A large file is read and written row by row; give it time. + signal: AbortSignal.timeout(5 * 60_000), + }, + ); + if (dryRun) setCheck(result); + else setDone(result); + } catch (err) { + const status = err instanceof ApiError ? err.status : 0; + setError( + status === 429 + ? t("recordImport.error.rateLimited") + : status === 413 + ? t("recordImport.error.tooLarge") + : status === 422 + ? t("recordImport.error.invalid") + : t("recordImport.error.failed"), + ); + } finally { + setBusy(false); + } + } + + const result = done ?? check; + + return ( + onOpenChange(false)} + > + {t("recordImport.close")} + + ) : ( +
+ + +
+ ) + } + > +
+
+ + + +
+ + {head ? ( + <> +
+ + {t("recordImport.mapping")} + + {fields.map((f) => ( +
+ + { + setCheck(null); + const v = e.target.value; + setMapping((m) => { + const next = { ...m }; + // One column feeds one field: taking it here frees it elsewhere. + for (const k of Object.keys(next) as FieldKey[]) { + if (v !== "" && next[k] === Number(v)) delete next[k]; + } + if (v === "") delete next[f.key]; + else next[f.key] = Number(v); + return next; + }); + }} + > + + {columns.map((name, i) => ( + + ))} + +
+ ))} +
+ +
+
+ + { + setCheck(null); + setDelimiter(e.target.value as Delimiter); + }} + > + {(["comma", "semicolon", "tab"] as const).map((d) => ( + + ))} + +
+
+ + { + setCheck(null); + setDecimal(e.target.value as "dot" | "comma"); + }} + > + + + +
+
+ + { + setCheck(null); + setDateOrder(e.target.value as "iso" | "dmy" | "mdy"); + }} + > + {(["iso", "dmy", "mdy"] as const).map((o) => ( + + ))} + +
+
+ +
+ + { + setCheck(null); + setHeader(v); + }} + /> +
+ {kind === "medications" ? ( +
+ + { + setCheck(null); + setReminders(v); + }} + /> +
+ ) : null} +

+ {t("recordImport.timezoneNote")} +

+ +
+ + + {head.rows.map((row, r) => ( + + {row.map((c, i) => ( + + ))} + + ))} + +
+ {c} +
+
+ + ) : null} + + {missing.length > 0 && head ? ( +

+ {t("recordImport.missing", { + fields: missing + .map((f) => t(`recordImport.field.${f.key}`)) + .join(", "), + })} +

+ ) : null} + + {error ? ( +

+ {error} +

+ ) : null} + + {result ? ( +
+

+ {done + ? t("recordImport.summaryDone", { + inserted: fmt.integer(result.inserted), + duplicates: fmt.integer(result.duplicates), + failed: fmt.integer(result.failed), + }) + : t("recordImport.summaryCheck", { + rows: fmt.integer(result.rows), + inserted: fmt.integer(result.inserted), + duplicates: fmt.integer(result.duplicates), + failed: fmt.integer(result.failed), + })} +

+ {result.markers && + (result.markers.created.length > 0 || + result.markers.matched.length > 0) ? ( +
+ {result.markers.matched.length > 0 ? ( +

+ + {t("recordImport.markersMatched")}{" "} + + {result.markers.matched + .map((m) => + m.name === m.marker + ? m.name + : `${m.name} (${m.marker})`, + ) + .join(", ")} +

+ ) : null} + {result.markers.created.length > 0 ? ( +

+ + {t("recordImport.markersCreated")}{" "} + + {result.markers.created.join(", ")} +

+ ) : null} +
+ ) : null} + {result.errors.length > 0 ? ( +
    + {result.errors.map((e) => ( +
  • + + {t("recordImport.line", { line: fmt.integer(e.line) })} + + + {t(`recordImport.reason.${e.reason}`)} + {e.field + ? ` (${t(`recordImport.field.${e.field}`)})` + : ""} + +
  • + ))} +
+ ) : null} + {result.errorsTruncated ? ( +

+ {t("recordImport.errorsTruncated")} +

+ ) : null} +
+ ) : null} +
+
+ ); +} diff --git a/src/components/settings/injection-sites-card.tsx b/src/components/settings/injection-sites-card.tsx index 21e82c34f..53cd607cd 100644 --- a/src/components/settings/injection-sites-card.tsx +++ b/src/components/settings/injection-sites-card.tsx @@ -74,6 +74,7 @@ export function InjectionSitesCard({ return ( = { nocode: "settings.withingsOauthError.nocode", nocreds: "settings.withingsOauthError.nocreds", token: "settings.withingsOauthError.token", + already_linked: "settings.withingsOauthError.already_linked", }; type WithingsOauthOutcome = @@ -126,6 +127,8 @@ const OAUTH_OUTCOME_REASONS = new Set([ "token", "rate_limited", "expired", + // The provider account is already connected to another record. + "already_linked", ]); /** diff --git a/src/components/settings/integrations/integration-health-copy.ts b/src/components/settings/integrations/integration-health-copy.ts index f9340dd4e..17e8f8cfa 100644 --- a/src/components/settings/integrations/integration-health-copy.ts +++ b/src/components/settings/integrations/integration-health-copy.ts @@ -130,6 +130,10 @@ export function unavailableItemLabel(item: string, t: Translate): string { return t("settings.integrations.oura.scope.items.cardio_age"); case "resilience": return t("settings.integrations.oura.scope.items.resilience"); + case "workout": + return t("settings.integrations.oura.scope.items.workout"); + case "heartrate": + return t("settings.integrations.oura.scope.items.heartrate"); case "activity": return t("settings.integrationCard.items.activity"); case "sleep": diff --git a/src/components/settings/layout-groups.ts b/src/components/settings/layout-groups.ts index 07e7de564..d778a9ecc 100644 --- a/src/components/settings/layout-groups.ts +++ b/src/components/settings/layout-groups.ts @@ -5,7 +5,6 @@ import { surfaceModule } from "@/lib/modules/surface"; import { DashboardSection } from "./dashboard-section"; import { InsightsSection } from "./insights-section"; import { MedicationsSection } from "./medications-section"; -import { MoodSection } from "./mood-section"; import { LabsSection } from "./labs-section"; import { IllnessSection } from "./illness-section"; import { DocumentsSection } from "./documents-section"; @@ -30,6 +29,11 @@ import { VorsorgeSection } from "./vorsorge-section"; * usable on the server. It holds references to the `"use client"` section * components, which a server component renders as ordinary client boundaries. * + * v1.44 — the hub holds order and view only ("Arrangement"). The mood tag + * management, the labs biomarker catalog and text recognition, and the + * injection sites moved to the module pages under Settings > Modules + * (`module-settings-pages.tsx`); `/settings/layout/mood` redirects there. + * * Each module section keeps its module-gate: a disabled module's row / subpage * does not render, read from the resolved `useAuth().user.modules` map (the * same map the nav, the Modules hub, and the Insights pills gate off). The gate @@ -76,12 +80,6 @@ export const LAYOUT_GROUPS: ReadonlyArray = [ descriptionKey: "settings.sections.layout.medications.description", Body: MedicationsSection, }, - { - id: "mood", - titleKey: "settings.sections.layout.mood.title", - descriptionKey: "settings.sections.layout.mood.description", - Body: MoodSection, - }, { id: "labs", titleKey: "settings.sections.layout.labs.title", diff --git a/src/components/settings/managed-integration-status.tsx b/src/components/settings/managed-integration-status.tsx index 22f5de00e..6f84aa551 100644 --- a/src/components/settings/managed-integration-status.tsx +++ b/src/components/settings/managed-integration-status.tsx @@ -1,18 +1,34 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; -import { Droplet, ExternalLink, Plug } from "lucide-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Droplet, ExternalLink, Link2, Plug, Unplug } from "lucide-react"; -import { IntegrationCard } from "@/components/settings/integrations/integration-card"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { + IntegrationCard, + type IntegrationMenuAction, +} from "@/components/settings/integrations/integration-card"; +import { Button } from "@/components/ui/button"; import { OAUTH_INTEGRATIONS } from "@/components/settings/integrations/oauth-integration-card"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { SectionHeading } from "@/components/ui/section-heading"; -import { apiGet } from "@/lib/api/api-fetch"; +import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import type { ConnectionHealth } from "@/lib/integrations/connection-health"; -import { queryKeys } from "@/lib/query-keys"; +import { + isManagedOAuthProvider, + type ManagedOAuthProvider, +} from "@/lib/integrations/oauth-kit/managed-provider"; +import { loadDocument } from "@/lib/navigation/load-document"; +import { + aiInputDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; import { assertRecordSettingsResponseForRecord } from "@/lib/record-settings/response"; +import { accountLabel } from "@/lib/sharing/account-access-view"; type ManagedIntegrationKey = keyof typeof INTEGRATION_DISPLAY_NAMES; @@ -49,15 +65,18 @@ function iconFor(key: ManagedIntegrationKey) { * The managed record's connections, on the same card every integration in * Settings renders, from the same server-resolved `health`. * - * Read-only by construction: a guardian cannot connect, reconnect, resume, - * sync, test or disconnect a managed record's provider, because an OAuth flow - * cannot safely carry a record selector. So the cards carry no menu and no - * button, and one sentence above them says why, with the page that explains - * the boundary. + * A guardian connects and disconnects the record's OAuth providers here. The + * connect starts with a fetch (`POST /api/record-settings/integrations/ + * /connect`, which carries the record-session headers a navigation + * cannot) and then sends the browser to the provider; tokens and data land on + * the managed record. Every card says whose record it connects for. Syncing, + * testing and provider credentials stay out: the record syncs on its own + * schedule once connected. */ export function ManagedIntegrationStatus() { const { t } = useTranslations(); const { user } = useAuth(); + const queryClient = useQueryClient(); const activeRecord = user?.accountAccess?.active ?? null; const recordId = activeRecord?.accountId ?? null; const statusQuery = useQuery({ @@ -72,6 +91,59 @@ export function ManagedIntegrationStatus() { enabled: activeRecord?.recordKind === "managed" && recordId !== null, }); + // Named the way the switcher and the banner name it: a profile created + // without a display name still has a name to connect for. + const recordName = activeRecord ? accountLabel(activeRecord) : ""; + + const invalidateStatus = () => + queryClient.invalidateQueries({ + queryKey: queryKeys.recordSettingsIntegrations(recordId ?? ""), + }); + + const connect = useMutation({ + mutationFn: async (provider: ManagedOAuthProvider) => + apiPost<{ authorizationUrl: string }>( + `/api/record-settings/integrations/${provider}/connect`, + ), + onSuccess: (started) => loadDocument(started.authorizationUrl), + onError: (failure, provider) => + showErrorToast( + failure instanceof ApiError && failure.status === 409 + ? t("settings.sharedRecord.integrationNoClientApp", { + name: recordName, + provider: INTEGRATION_DISPLAY_NAMES[provider], + }) + : t("settings.sharedRecord.integrationConnectFailed", { + provider: INTEGRATION_DISPLAY_NAMES[provider], + }), + { cause: failure }, + ), + }); + + const disconnect = useMutation({ + mutationFn: async (provider: ManagedOAuthProvider) => + apiPost(`/api/record-settings/integrations/${provider}/disconnect`), + onSuccess: (_result, provider) => { + void invalidateStatus(); + // A record-settings write; the capability answer reads the record's + // connections, so it is evicted with the rest of the AI inputs. + void invalidateKeys(queryClient, aiInputDependentKeys); + toastWrittenOutcome( + "success", + t("settings.integrationDisconnected", { + provider: INTEGRATION_DISPLAY_NAMES[provider], + }), + ); + }, + onError: (failure, provider) => + showErrorToast( + t("settings.integrationDisconnectFailed", { + provider: INTEGRATION_DISPLAY_NAMES[provider], + }), + { cause: failure }, + ), + }); + if (statusQuery.isError) { return ( ({ integration, health: null }), ); + /** What a guardian can do with one OAuth provider of the record. */ + function managedControls(provider: ManagedOAuthProvider) { + const providerName = INTEGRATION_DISPLAY_NAMES[provider]; + const pending = + (connect.isPending && connect.variables === provider) || + (disconnect.isPending && disconnect.variables === provider); + const forRecord = ( +

+ {t("settings.sharedRecord.integrationConnectsFor", { + name: recordName, + })} +

+ ); + const menu: IntegrationMenuAction[] = [ + { + id: "disconnect", + label: t("settings.sharedRecord.integrationDisconnect"), + icon: Unplug, + destructive: true, + disabled: pending, + onSelect: () => disconnect.mutate(provider), + confirm: { + title: t("settings.sharedRecord.integrationDisconnectTitle", { + provider: providerName, + name: recordName, + }), + body: t("settings.sharedRecord.integrationDisconnectBody", { + name: recordName, + }), + confirmLabel: t("settings.sharedRecord.integrationDisconnect"), + }, + }, + ]; + return { + children: forRecord, + menu, + primary: { + onReconnect: () => connect.mutate(provider), + pending, + }, + connectAction: ( + + ), + }; + } + return (
-

- {t("settings.sharedRecord.integrationStatusDetail")}{" "} +

+ {t("settings.sharedRecord.integrationStatusDetail", { + name: recordName, + })}{" "}

-
    +
      {entries.map((entry) => (
    • ))} diff --git a/src/components/settings/mcp-section.tsx b/src/components/settings/mcp-section.tsx index 6b448a5d6..ca1eb59fd 100644 --- a/src/components/settings/mcp-section.tsx +++ b/src/components/settings/mcp-section.tsx @@ -17,22 +17,12 @@ * least-privilege read scope and nothing more. */ import { useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Copy, Key, Link2, Loader2, Plug, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; +import { ConfirmButton } from "@/components/ui/confirm-button"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; @@ -42,15 +32,12 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { ModuleStateCard } from "@/components/settings/module-state-card"; import { useAuth } from "@/hooks/use-auth"; import { formatDate, formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; -import { - aiInputDependentKeys, - invalidateKeys, - queryKeys, -} from "@/lib/query-keys"; -import { apiFetchRaw, apiGet, apiPatch } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; import { recentProofErrorMessage, throwIfReproofRequired, @@ -169,39 +156,17 @@ function McpConnectionsCard() { ? formatDateTime(conn.lastUsedAt) : t("settings.mcp.connectionNeverUsed")}

      - - - - - - - - {t("settings.mcp.connectionRevoke")} - - - {t("settings.mcp.connectionRevokeDescription")} - - - - - {t("common.cancel")} - - handleRevoke(conn.id)} - > - {t("settings.mcp.connectionRevoke")} - - - - + void handleRevoke(conn.id)} + slot="mcp-connection-revoke" + icon={} + destructive + /> ))}
    @@ -211,10 +176,16 @@ function McpConnectionsCard() { ); } +/** + * The connector's state, with a link to the one switch in Settings > Modules. + * Turning the module off pauses the endpoint: it refuses every call, while the + * tokens and connections below stay as they are and work again once it is on. + * Nothing here or in Modules revokes a token as a side effect (#1170); a token + * goes only through its own revoke. + */ function McpEnableCard() { const { t } = useTranslations(); const { user } = useAuth(); - const queryClient = useQueryClient(); // The opt-in module is OFF until explicitly enabled — `user.modules.mcp` is // the resolved state (true only when the user turned it on). @@ -222,53 +193,29 @@ function McpEnableCard() { const endpoint = typeof window !== "undefined" ? `${window.location.origin}/mcp` : "/mcp"; - const toggle = useMutation({ - mutationKey: queryKeys.modulesPrefs(), - mutationFn: async (next: boolean) => - apiPatch("/api/auth/me/modules", { mcp: next }), - onSuccess: () => { - void invalidateKeys(queryClient, aiInputDependentKeys); - toast.success(t("settings.sections.modules.saved")); - }, - onError: (failure) => - showErrorToast(t("settings.sections.modules.error"), { cause: failure }), - }); - return ( - - - -
    -
    - - {t("settings.mcp.enableToggleLabel")} - - toggle.mutate(next)} - aria-label={t("settings.mcp.enableToggleLabel")} - /> -
    - -
    -

    - {t("settings.mcp.endpointLabel")} -

    - - {endpoint} - -

    - {t("settings.mcp.connectDescription")} -

    -
    + +

    {t("settings.mcp.offKeepsTokens")}

    +
    +

    + {t("settings.mcp.endpointLabel")} +

    + + {endpoint} + +

    + {t("settings.mcp.connectDescription")} +

    - +
    ); } @@ -508,39 +455,17 @@ function McpTokensCard() { ? formatDateTime(tok.lastUsedAt) : t("settings.tokenNeverUsed")}

    - - - - - - - - {t("settings.tokenRevoke")} - - - {t("settings.tokenRevokeDescription")} - - - - - {t("common.cancel")} - - handleRevoke(tok.id)} - > - {t("settings.tokenRevokeAction")} - - - - + void handleRevoke(tok.id)} + slot="mcp-token-revoke" + icon={} + destructive + /> ); })} diff --git a/src/components/settings/medications-section.tsx b/src/components/settings/medications-section.tsx index ba552e221..67d897f94 100644 --- a/src/components/settings/medications-section.tsx +++ b/src/components/settings/medications-section.tsx @@ -11,8 +11,6 @@ import { MedicationViewToggle } from "@/components/medications/medication-view-t import { SettingsCard } from "@/components/settings/settings-card"; import { Skeleton } from "@/components/ui/skeleton"; import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { InjectionSitesCard } from "@/components/settings/injection-sites-card"; -import { useAuth } from "@/hooks/use-auth"; import { apiGet } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; import { applyMedicationOrder } from "@/lib/medications/medication-order"; @@ -24,7 +22,7 @@ import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layou * from a Layout-hub child to its own standalone nav entry and gathered the * medication-specific preferences here. v1.18.1 (D3) made medications a * toggleable fail-open module, so this nav entry hides when the account - * turns the module off (the medication data routes stay live). Hosts three + * turns the module off (the medication data routes stay live). Hosts two * blocks: * 1. The list VIEW preference — cards vs table, written optimistically * through the same `PUT /api/medications/layout` the page header @@ -32,8 +30,9 @@ import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layou * 2. The manual ORDER editor — two grouped lists (Aktiv / Inaktiv) * with drag + arrow reordering, flushed by an explicit Save, * persisted as active-ids-then-inactive-ids on the same layout row. - * 3. Injection-site exclusions — moved here from the account profile so - * every medication-specific preference lives on one screen. + * + * v1.44 — Arrangement holds order and view only; the injection-site + * exclusions moved to the medications page under Modules. * * The list view + order both write through the same `/api/medications/layout` * contract the /medications page reads, so a save here repaints both views. @@ -49,7 +48,6 @@ interface MedicationListEntry { export function MedicationsSection() { const { t } = useTranslations(); - const { isAuthenticated } = useAuth(); const { layout, isLayoutLoading, setView } = useMedicationListLayout(); const { data: medications, isLoading } = useQuery({ @@ -120,11 +118,6 @@ export function MedicationsSection() { )} - - {/* v1.18.0 — injection-site exclusions are a medication setting; - they moved here from the account profile so all medication-specific - preferences live in one place. */} -
    ); } diff --git a/src/components/settings/module-settings-link-card.tsx b/src/components/settings/module-settings-link-card.tsx new file mode 100644 index 000000000..380c1c275 --- /dev/null +++ b/src/components/settings/module-settings-link-card.tsx @@ -0,0 +1,43 @@ +"use client"; + +/** + * The way from a module's arrangement page (`/settings/layout/`) to its + * own page under Modules, for the modules that have one. + * + * Kept apart from `module-settings-pages.tsx` so the arrangement route ships + * the card it renders, not the labs, mood and injection-site settings that + * module holds. + */ + +import Link from "next/link"; +import { ChevronRight, SlidersHorizontal } from "lucide-react"; + +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { Button } from "@/components/ui/button"; +import { useTranslations } from "@/lib/i18n/context"; +import type { ModuleSettingsPageId } from "./module-settings-page-ids"; + +export function ModuleSettingsLinkCard({ id }: { id: ModuleSettingsPageId }) { + const { t } = useTranslations(); + return ( + + + + + + + ); +} diff --git a/src/components/settings/module-settings-page-ids.ts b/src/components/settings/module-settings-page-ids.ts new file mode 100644 index 000000000..622a72d57 --- /dev/null +++ b/src/components/settings/module-settings-page-ids.ts @@ -0,0 +1,40 @@ +import type { ModuleKey } from "@/lib/modules/registry"; +import { surfaceModule } from "@/lib/modules/surface"; + +/** + * Server-safe ids and copy keys of the module pages under Settings > Modules + * (`/settings/modules/`). The route reads them in + * `generateStaticParams()`, so they live apart from the client bodies in + * `module-settings-pages.tsx`. + */ +export const MODULE_SETTINGS_PAGE_IDS = [ + "medications", + "mood", + "labs", +] as const; + +export type ModuleSettingsPageId = (typeof MODULE_SETTINGS_PAGE_IDS)[number]; + +export function isModuleSettingsPageId( + value: string, +): value is ModuleSettingsPageId { + return (MODULE_SETTINGS_PAGE_IDS as readonly string[]).includes(value); +} + +/** i18n keys of a page's heading, under `settings.sections.modules.pages`. */ +export function moduleSettingsPageKeys(id: ModuleSettingsPageId): { + titleKey: string; + descriptionKey: string; +} { + return { + titleKey: `settings.sections.modules.pages.${id}.title`, + descriptionKey: `settings.sections.modules.pages.${id}.description`, + }; +} + +/** The module a page belongs to, from the surface map. */ +export function moduleSettingsPageModule( + id: ModuleSettingsPageId, +): ModuleKey | undefined { + return surfaceModule(`settings-module:${id}`); +} diff --git a/src/components/settings/module-settings-pages.tsx b/src/components/settings/module-settings-pages.tsx new file mode 100644 index 000000000..bd06c6f9c --- /dev/null +++ b/src/components/settings/module-settings-pages.tsx @@ -0,0 +1,90 @@ +"use client"; + +/** + * The pages a module's own settings live on: `/settings/modules/`. + * + * Settings > Arrangement holds order and view only. What a module needs + * beyond that (the biomarker catalog and on-device text recognition for + * labs, the mood tags, the injection sites a medication rotation leaves out) + * lives on the module's page under Modules, reached from the module's row in + * Settings > Modules and from the wrench on the module's own page. Each page + * links to the module's arrangement page and back. + * + * Every page is gated on its module through the surface map + * (`settings-module:`), fail-open like the rest of Settings. + */ + +import Link from "next/link"; +import { ArrowUpDown, ChevronRight } from "lucide-react"; + +import { LabsModuleSettings } from "@/components/labs/labs-settings"; +import { InjectionSitesCard } from "@/components/settings/injection-sites-card"; +import { MoodSection } from "@/components/settings/mood-section"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations } from "@/lib/i18n/context"; +import type { ModuleSettingsPageId } from "./module-settings-page-ids"; + +/** A card pointing at the module's order-and-view page under Arrangement. */ +function ArrangementLinkCard({ id }: { id: ModuleSettingsPageId }) { + const { t } = useTranslations(); + return ( + + + + + + + ); +} + +function MedicationsModulePage() { + const { isAuthenticated } = useAuth(); + return ( +
    + + +
    + ); +} + +function LabsModulePage() { + return ( +
    + + +
    + ); +} + +function MoodModulePage() { + return ; +} + +export const MODULE_SETTINGS_PAGE_BODIES: Record< + ModuleSettingsPageId, + () => React.JSX.Element +> = { + medications: MedicationsModulePage, + mood: MoodModulePage, + labs: LabsModulePage, +}; + +export function ModuleSettingsPageBody({ id }: { id: ModuleSettingsPageId }) { + const Body = MODULE_SETTINGS_PAGE_BODIES[id]; + return ; +} diff --git a/src/components/settings/module-state-card.tsx b/src/components/settings/module-state-card.tsx new file mode 100644 index 000000000..410a46578 --- /dev/null +++ b/src/components/settings/module-state-card.tsx @@ -0,0 +1,72 @@ +"use client"; + +/** + * A module's on/off state, shown where the module is configured, with a link + * to the one switch in Settings > Modules. + * + * The Coach and the MCP connector each had a second switch on their own page + * beside the one in Modules: two controls for one state, and a person who + * flipped one never knew the other existed. The switch stays in Modules (the + * row carries `id="module-"`); the module's page says what the state is + * and where to change it. + */ + +import Link from "next/link"; +import { ChevronRight, type LucideIcon } from "lucide-react"; + +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { useTranslations } from "@/lib/i18n/context"; +import type { ModuleKey } from "@/lib/modules/registry"; + +export function ModuleStateCard({ + moduleKey, + enabled, + icon, + title, + description, + anchor, + slot, + children, +}: { + moduleKey: ModuleKey; + enabled: boolean; + icon: LucideIcon; + title: string; + description: string; + anchor: string; + slot: string; + /** Body copy between the header and the link, e.g. what "off" keeps. */ + children?: React.ReactNode; +}) { + const { t } = useTranslations(); + return ( + + + {enabled + ? t("settings.moduleState.on") + : t("settings.moduleState.off")} + + } + /> + {children} + + + + + ); +} diff --git a/src/components/settings/module-toggle-row.tsx b/src/components/settings/module-toggle-row.tsx index 6fc032a41..9c7e7cd5c 100644 --- a/src/components/settings/module-toggle-row.tsx +++ b/src/components/settings/module-toggle-row.tsx @@ -92,7 +92,12 @@ export function ModuleToggleRow({ // is in flight. const interactive = !locked && disabledReason == null; return ( -
    + // `id="module-"` is the anchor other pages link to when they show a + // module's state and send the person here to change it. +
    { + // A request that never reaches the server (offline, a dropped connection) + // is a refused change like any other. const response = await apiFetchRaw(endpoint, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled }), - }); + }).catch(() => null); - if (!response.ok) { - const json = (await response.json()) as { error?: string }; - throw new Error(json.error || fallbackError); + if (!response?.ok) { + const json = (await response?.json().catch(() => null)) as { + error?: string; + } | null; + throw new Error(json?.error || fallbackError); } } @@ -42,3 +63,33 @@ export function rollbackChannelEnabled( ): void { queryClient.setQueryData(queryKey, previous); } + +/** + * The mutation behind a channel's enable switch. A toggle is no form, so its + * outcome is a toast either way; the switch itself already shows the state. + */ +export function useChannelEnabledToggle( + endpoint: string, + queryKey: QueryKey, +) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (enabled) => + persistChannelEnabled(endpoint, enabled, t("settings.savingError")), + onMutate: async (enabled) => { + await queryClient.cancelQueries({ queryKey }); + return optimisticallySetChannelEnabled(queryClient, queryKey, enabled); + }, + onSuccess: () => { + toastWrittenOutcome("success", t("settings.saved")); + }, + onError: (err, _enabled, previous) => { + rollbackChannelEnabled(queryClient, queryKey, previous); + showErrorToast(err.message, { cause: err }); + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey }); + }, + }); +} diff --git a/src/components/settings/notifications-section.tsx b/src/components/settings/notifications-section.tsx index cbee3ccc3..788e23438 100644 --- a/src/components/settings/notifications-section.tsx +++ b/src/components/settings/notifications-section.tsx @@ -1,78 +1,148 @@ "use client"; import Link from "next/link"; -import { Bell, Smartphone } from "lucide-react"; +import { Bell, BellRing, ChevronRight, Smartphone } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; import { useTranslations } from "@/lib/i18n/context"; +import { isSurfaceVisible } from "@/lib/modules/surface"; +import { CoachNudgeCard } from "@/components/settings/coach-nudge-card"; import { LowStockCard } from "@/components/settings/low-stock-card"; import { SectionHeading } from "@/components/ui/section-heading"; import { MoodReminderCard } from "@/components/settings/mood-reminder-card"; import { NotificationContentCard } from "@/components/settings/notification-content-card"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { VorsorgeReminderSwitchesCard } from "@/components/measurement-reminders/vorsorge-settings"; import { Button } from "@/components/ui/button"; +import { InlineAction } from "@/components/ui/inline-action"; /** * `` — Settings → Notifications ("Benachrichtigungen"). * - * v1.18.0 — the single module-gated home for reminder TYPES. Earlier the - * settings carried two near-identical entries: this Notifications screen and a - * separate "Erinnerungen" hub that only deep-linked into the canonical editors - * ("doppelt gemoppelt"). The hub is gone (its route 301-redirects here); the - * reminder TYPES now live centrally on this one screen. + * The one home for every reminder kind. A reminder whose settings belong to + * one place is set here; a reminder that is set where its subject lives (a + * medication's dose times on the medication, the cycle reminders in the + * per-event matrix) gets a row here that says where, so nothing has to be + * found by luck. * - * Each reminder-type card is shown ONLY when its module is enabled, read from - * the resolved `useAuth().user.modules` map (the same `/auth/me` map nav and - * Insights pills gate off). Mood → `mood`, low-stock runway → `medications` - * (a toggleable fail-open module since D3, so a missing key reads as enabled - * and a stale `/me` payload never blanks a card). + * Each card is shown only while its module is on, read from the resolved + * `useAuth().user.modules` map and failing open, so a stale `/me` payload + * never blanks a card. * - * v1.25.7 — delivery channels move to Settings → Integrationen (channels are - * delivery providers, the same family as the connected services). This screen - * keeps ONLY the reminder-TYPE content; `/settings/channels` 301-redirects to - * `/settings/integrations#channels`. - * - * v1.18.6 — the page heading + subtitle come from the shared - * ``. The proactive-Coach nudge card lives under - * Settings → Coach (it is a Coach setting, not a generic notification). + * v1.18.0 — the separate "Erinnerungen" hub is gone; `/settings/reminders` + * redirects here. v1.25.7 — the delivery channels live under Integrations + * (they are delivery providers); `/settings/channels` redirects there and this + * page links to them. v1.44 — the Coach nudges came back from Settings > Coach + * and the checkup reminder switches from the checkups arrangement page; both + * old places link here (`/settings/coach#coach-nudge` forwards). */ export function NotificationsSection() { const { isAuthenticated, user } = useAuth(); const { t } = useTranslations(); + // The Coach gate reads the surface map like the Coach section does, after + // hydration, so the server render and the first client paint agree. + const mounted = useMounted(); - // v1.18.0 — module-gated per-type visibility. Fail OPEN (`!== false`) - // so a stale `/me` payload without the module map keeps every card visible. const moodEnabled = user?.modules?.mood !== false; const medsEnabled = user?.modules?.medications !== false; - const anyReminder = moodEnabled || medsEnabled; + const cycleEnabled = user?.modules?.cycle !== false; + const coachEnabled = + !mounted || isSurfaceVisible("settings:coach", user?.modules); return (
    - {/* Group 1 — Reminders ("what you receive"). Rendered only when at - least one reminder-type card is visible, so a fully-disabled - account never shows an empty heading. */} - {anyReminder ? ( -
    - + {/* Group 1 — Reminders ("what you receive"). The checkup reminders are + not a module and always show, so the group always has content. */} +
    + - {/* Mood check-in reminder — only when the mood module is enabled. */} - {moodEnabled ? ( -
    - -
    - ) : null} + {moodEnabled ? ( +
    + +
    + ) : null} - {/* Medication low-stock runway — only when the medications module - is enabled (toggleable, fail-open, since D3). */} - {medsEnabled ? ( -
    - -
    - ) : null} -
    - ) : null} + {medsEnabled ? ( +
    + +
    + ) : null} + + {coachEnabled ? ( +
    + +
    + ) : null} + +
    + +
    + + {/* Reminders set where their subject lives. One row each, saying + where; nothing here writes. */} + {medsEnabled || cycleEnabled ? ( + + +
      + {medsEnabled ? ( +
    • +

      + {t("settings.sections.notifications.elsewhere.doses.title")} +

      +

      + {t( + "settings.sections.notifications.elsewhere.doses.description", + )} +

      + + + {t( + "settings.sections.notifications.elsewhere.doses.link", + )} + +
    • + ) : null} + {cycleEnabled ? ( +
    • +

      + {t("settings.sections.notifications.elsewhere.cycle.title")} +

      +

      + {t( + "settings.sections.notifications.elsewhere.cycle.description", + )} +

      + + + {t( + "settings.sections.notifications.elsewhere.cycle.link", + )} + +
    • + ) : null} +
    +
    + ) : null} +
    {/* Group 2 — How notifications look on the phone. Not module-gated: it applies to every notification the account receives. */} diff --git a/src/components/settings/ntfy-card.tsx b/src/components/settings/ntfy-card.tsx index 92fe4546d..f97975c33 100644 --- a/src/components/settings/ntfy-card.tsx +++ b/src/components/settings/ntfy-card.tsx @@ -14,11 +14,8 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { TestConnectionButton } from "@/components/settings/test-connection-button"; import { isNtfyFormDirty } from "@/components/settings/channel-form-dirty"; -import { - optimisticallySetChannelEnabled, - persistChannelEnabled, - rollbackChannelEnabled, -} from "@/components/settings/notification-channel-toggle"; +import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { useChannelEnabledToggle } from "@/components/settings/notification-channel-toggle"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; @@ -37,10 +34,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) { const [serverUrl, setServerUrl] = useState("https://ntfy.sh"); const [topic, setTopic] = useState(""); const [authToken, setAuthToken] = useState(""); - const [saveMsg, setSaveMsg] = useState(null); - const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>( - null, - ); + const [saveError, setSaveError] = useState(null); const { data: settings } = useQuery({ queryKey: queryKeys.settingsNtfy(), @@ -61,9 +55,14 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) { setTopic(settings!.topic); } const dirty = isNtfyFormDirty({ serverUrl, topic, authToken }, settings); + // The enable switch saves on tap; the form's Save covers the fields below. + const toggle = useChannelEnabledToggle( + "/api/settings/ntfy", + queryKeys.settingsNtfy(), + ); const save = useMutation({ - mutationFn: async (enabled: boolean) => { + mutationFn: async () => { const res = await apiFetchRaw("/api/settings/ntfy", { method: "PUT", headers: { "Content-Type": "application/json" }, @@ -71,7 +70,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) { serverUrl, topic, authToken: authToken || undefined, - enabled, + enabled: settings?.enabled ?? false, }), }); if (!res.ok) { @@ -93,46 +92,13 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.settingsNtfy() }); - setSaveMsg(t("settings.saved")); - setSaveMsgType("success"); + setSaveError(null); + toastWrittenOutcome("success", t("settings.saved")); setAuthToken(""); }, + // A form shows its refusal inline, next to the fields it is about. onError: (err: Error) => { - setSaveMsg(err.message); - setSaveMsgType("error"); - }, - }); - - const toggleEnabled = useMutation< - void, - Error, - boolean, - NtfySettings | undefined - >({ - mutationFn: (enabled) => - persistChannelEnabled("/api/settings/ntfy", enabled, t("common.error")), - onMutate: async (enabled) => { - const queryKey = queryKeys.settingsNtfy(); - await queryClient.cancelQueries({ queryKey }); - setSaveMsg(null); - setSaveMsgType(null); - return optimisticallySetChannelEnabled( - queryClient, - queryKey, - enabled, - ); - }, - onSuccess: () => { - setSaveMsg(t("settings.saved")); - setSaveMsgType("success"); - }, - onError: (err, _enabled, previous) => { - rollbackChannelEnabled(queryClient, queryKeys.settingsNtfy(), previous); - setSaveMsg(err.message); - setSaveMsgType("error"); - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.settingsNtfy() }); + setSaveError(err.message); }, }); @@ -150,15 +116,15 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) { toggleEnabled.mutate(checked)} - disabled={save.isPending || toggleEnabled.isPending} + onCheckedChange={(checked) => toggle.mutate(checked)} + disabled={!settings || save.isPending || toggle.isPending} />
    { e.preventDefault(); - save.mutate(settings?.enabled ?? false); + save.mutate(); }} className="space-y-4" > @@ -199,12 +165,9 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
    - {saveMsg && (saveMsgType === "error" || !dirty) && ( -

    - {saveMsg} + {saveError && ( +

    + {saveError}

    )} @@ -216,7 +179,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) { />
+ {/* Workout GPS tracks: kept only on opt-in. */} + + {/* Retention */} - {/* Export */} + {/* Sessions, trusted devices and the security activity live under + Security since v1.44; the row keeps them reachable from here, where + people looked for them before. */} - @@ -237,11 +238,6 @@ export function PrivacySection() { - - {/* Active sessions + trusted devices + security activity (embedded) */} - - -
); } diff --git a/src/components/settings/report-selection/report-scope-picker.tsx b/src/components/settings/report-selection/report-scope-picker.tsx index 29825262f..11679fbb8 100644 --- a/src/components/settings/report-selection/report-scope-picker.tsx +++ b/src/components/settings/report-selection/report-scope-picker.tsx @@ -110,7 +110,7 @@ export function ReportScopePicker({ return (
{/* The one-click way to a complete report. An inline action row - (UI-STANDARDS §11): the sentence wraps, the action stays beside it. + (ui-standards.md §11): the sentence wraps, the action stays beside it. It is offered, never applied — and it stays offered so a person can come back to the standard set after experimenting. On a phone the action's own label is wider than half the row, so beside it the diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts index 843203284..b6f69ed40 100644 --- a/src/components/settings/section-slugs.ts +++ b/src/components/settings/section-slugs.ts @@ -114,6 +114,10 @@ export const SETTINGS_SECTION_SLUGS = [ // pieces into one coherent privacy pane. Sits next to `advanced` (the // destructive-action home) and before `about`. "privacy", + // v1.44 — "Preview features" (per-user opt-ins, Settings > System) is not + // listed until the first preview it switches, the topic pages, ships: a + // switch that changes nothing is a promise the page cannot keep. The stored + // flag, `/api/settings/preview-features` and its backup round-trip stay. "about", ] as const; diff --git a/src/components/settings/security-section/index.tsx b/src/components/settings/security-section/index.tsx index ed7e8dbfe..6008b7d75 100644 --- a/src/components/settings/security-section/index.tsx +++ b/src/components/settings/security-section/index.tsx @@ -7,6 +7,9 @@ import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; import { PasswordCard } from "@/components/settings/password-card"; +import { SecurityActivityCard } from "@/components/settings/security-activity-card"; +import { SecuritySessionsCard } from "@/components/settings/security-sessions-card"; +import { TrustedDevicesCard } from "@/components/settings/trusted-devices-card"; import { TotpCard } from "./totp-card"; import { SecurityKeysCard, type WebauthnKeyInfo } from "./security-keys-card"; import { PasskeyListSection } from "./passkey-list-section"; @@ -79,6 +82,15 @@ export function SecuritySection() { `/api/auth/me/mfa` that never answers must not take it down with the cards that do need the status. */} + + {/* v1.44 — who is signed in and what happened, beside the controls + that decide how one signs in. These lived on the privacy page; + `/settings/privacy#sessions` (and the other two anchors) forward + here. Outside the status conditional for the same reason as the + password: they read their own routes. */} + + +
); } diff --git a/src/components/settings/security-section/passkey-list-section.tsx b/src/components/settings/security-section/passkey-list-section.tsx index a9e9281a9..34d9ed2cb 100644 --- a/src/components/settings/security-section/passkey-list-section.tsx +++ b/src/components/settings/security-section/passkey-list-section.tsx @@ -27,6 +27,7 @@ import { } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { ConfirmButton } from "@/components/ui/confirm-button"; import { Input } from "@/components/ui/input"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; @@ -311,46 +312,21 @@ export function PasskeyListSection({ > - - - - - - - - {t("settings.deletePasskey")} - - - {t("settings.deletePasskeyDescription")} - - - - - {t("common.cancel")} - - remove.mutate(pk.id)} - > - {remove.isPending && ( - - )} - {t("common.delete")} - - - - + remove.mutate(pk.id)} + pending={remove.isPending && remove.variables === pk.id} + disabled={remove.isPending} + size="icon" + variant="ghost" + className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0" + icon={} + destructive + />
)}