Compare commits

...
396 Commits
Author SHA1 Message Date
Marc-André Bombeck ed05c49f6f Merge pull request #668 from MBombeck/release/v1.34.2
release/v1.34.2
2026-07-30 12:29:54 +02:00
Marc-André Bombeck b8c9a519c1 chore(release): v1.34.2 — count a re-sent workout once on every surface; re-anchor the service-worker version
Carries the four fixes that landed after the v1.34.1 tag plus the two found while
reviewing them.

The workout collapse that shipped in that batch only worked on the workout list,
because it keyed a session on a field three of the five callers never fetched.
Those three kept averaging one session into a person's baseline more than once.
The service-worker fallback version had drifted a release behind with nothing
checking it; both halves now have a gate.
2026-07-30 11:58:20 +02:00
Marc-André Bombeck 17ea7bea2d fix(sw): re-anchor the fallback cache version and gate it against the release
The service worker derives its cache names from the version injected by
`sw-version.js`, and falls back to a literal compiled into `sw.js` when that
import fails. The prebuild step rewrites both from `package.json`, so both were
correct as long as a release rebuilt. One did not, and the literal trailed the
release by a version while every test stayed green.

Nothing checked it. The generated file is compared against `package.json`, and
the behavioural suite deliberately reads the literal out of the source so its
cache-name assertions cannot drift from the literal they exercise. Reasonable
apart, and together they left the literal itself unverified.

The effect is confined to the degraded path and is still worth closing: a client
that cannot load `sw-version.js` computes the previous release's cache names, so
it keeps serving the old shell and its cleanup treats the current caches as the
stale ones.

Re-anchors the literal and adds the equality as a gate, since it is a question
with an exact answer. A version bump committed without a rebuild now fails
locally instead of shipping.
2026-07-30 11:55:16 +02:00
Marc-André Bombeck 00c282d311 fix(workouts): collapse a re-sent session on every surface, not just the list
The canonical picker keyed a session on sport, start, end and duration. Only the
workout list selected the end timestamp; the Coach narration, the per-sport
baseline and the deep-link cluster passed rows without it, so those rows carried
no session identity at all and the duplicate survived. One record counted once in
the list and two or three times everywhere else, including in the averages a
person is compared against.

Drop the end timestamp from the identity. On a workout with pauses it measures
wall-clock time while the duration counts only moving time, so two re-sends of
one session can differ in that field alone and the stricter key rejected a
genuine match. It also bought nothing in exchange: nobody starts a second
workout of the same sport in the same instant with the same duration.

The deep-link cluster now selects the duration it needs. A structural guard
freezes the call-site set and records which file owns each caller's select, so a
new surface cannot join the set without fetching the identity, and the identity
cannot grow a field only some callers fetch.
2026-07-30 11:54:58 +02:00
Marc-André Bombeck 3811d4592e Merge pull request #666 from MBombeck/release/v1.34.1
docs: polish v1.34.1 release notes and remove planning files
2026-07-30 11:34:00 +02:00
Marc-André Bombeck 83af211641 chore(openapi): regenerate the spec for the badminton sport type
The badminton mapping added the enum member in the Zod registry and left the
generated spec behind, so the drift gate failed. One line, `- badminton`.

Third failure this branch revealed in sequence: the formatter hid a red test,
the red test hid this. Each gate only runs once the one before it passes, so a
single unformatted line kept two real problems invisible.
2026-07-30 11:01:11 +02:00
Marc-André Bombeck 1f87e63662 fix(insights): restore the shared card rhythm the two assessment cards lost
The spacing pass on this branch tightened `insight-status-card` to `gap-1.5`
and left `mood-insights-sections` at `gap-2`, so two sibling cards that are
meant to read as one family stopped matching. `assessment-spine-order.test.ts`
exists to hold exactly that contract and went red as soon as the formatter
stopped masking it: `format:check` failed first in the job, so the unit tests
never ran and the divergence shipped unnoticed onto the branch.

Bring the mood card to the same value rather than widen the other one back.
The direction of travel is the tighter rhythm, and both allowed steps are on
the sanctioned spacing scale.

The test's expected string moves with the contract, not around it: it still
asserts that both cards carry the SAME value, which is the whole point of it.
2026-07-30 10:49:06 +02:00
Marc-André Bombeck bc94223423 style(insights): run prettier on the metric stat strip
The formatter joins one JSX line that was left wrapped. No behaviour change.
It is the only thing standing between this branch and a green gate: typecheck,
tests, e2e, integration and both image builds already pass, and `format:check`
fails on this file alone.
2026-07-30 10:33:24 +02:00
Marc-André Bombeck c83254032e fix(google-health): map badminton workouts 2026-07-29 22:24:04 +02:00
Marc-André Bombeck 0c7b958b1b fix(workouts): collapse repeated same-source sessions 2026-07-29 21:30:52 +02:00
Marc-André Bombeck 90b1a4eaee fix(insights): improve narrative contrast and spacing 2026-07-29 21:28:29 +02:00
Marc-André Bombeck ab450bbfd1 fix(dashboard): keep hero headline when score lead is removed 2026-07-29 21:20:16 +02:00
Marc-André Bombeck e1c351e2b7 docs: polish release notes and remove planning artifacts 2026-07-29 19:19:18 +02:00
Marc-André Bombeck 9e85eb13a1 Merge pull request #665 from MBombeck/release/v1.34.1
v1.34.1 Security and reliability release
2026-07-29 17:54:14 +02:00
Marc-André Bombeck 7c79b1a472 ci: acknowledge intentional release scanner exports 2026-07-29 17:39:42 +02:00
Marc-André Bombeck b1e20f59dc release: prepare v1.34.1 2026-07-29 17:31:31 +02:00
Marc-André Bombeck bdca4f5724 fix(dashboard): compact score-only hero 2026-07-29 17:05:53 +02:00
Marc-André Bombeck 8a19699a59 docs(security): record v1.34.1 dependency audit 2026-07-29 16:51:05 +02:00
Marc-André Bombeck 1682c3b54d fix(web): render public pages under nonce CSP 2026-07-29 16:46:19 +02:00
Marc-André Bombeck aaa51fb494 docs: capture v1.34.2 dependency remediation 2026-07-29 16:45:39 +02:00
Marc-André Bombeck ee2c93a67b test(medications): assert bounded replay details 2026-07-29 16:43:22 +02:00
Marc-André Bombeck 92218ccc00 test(dashboard): isolate arrival reaction browser coverage 2026-07-29 16:34:58 +02:00
Marc-André Bombeck f54555d187 fix(build): stabilize release bundle gates 2026-07-29 16:19:30 +02:00
Marc-André Bombeck 271f0ed28a fix(medications): make import replay results honest 2026-07-29 16:11:45 +02:00
Marc-André Bombeck 5b695f43ea fix(analytics): restore device-priority sleep selection 2026-07-29 16:08:41 +02:00
Marc-André Bombeck fb02cf89b1 fix(jobs): reconcile durable fact inventory 2026-07-29 15:59:27 +02:00
Marc-André Bombeck 75aa850eae style: satisfy v1.34.1 format gate 2026-07-29 15:59:12 +02:00
Marc-André Bombeck b0a94b3423 fix(web): stabilize document and example downloads 2026-07-29 15:57:20 +02:00
Marc-André Bombeck fb9ae2ac61 fix(coach): keep stateless turns grounded 2026-07-29 15:56:19 +02:00
Marc-André Bombeck 9354c649fe fix(integrations): surface Google sync progress and canonical workouts 2026-07-29 15:55:37 +02:00
Marc-André Bombeck b9879e8829 fix(nightscout): pin approved private origins 2026-07-29 15:54:01 +02:00
Marc-André Bombeck 8db21ab718 fix(jobs): persist bounded success outcomes 2026-07-29 15:50:42 +02:00
Marc-André Bombeck 7acc711d9e test(01-24): record blocked merged release gate 2026-07-29 15:43:31 +02:00
Marc-André Bombeck 8b3b2a388e docs(01-24): record merged re-audit block 2026-07-29 15:26:59 +02:00
Marc-André Bombeck 8a3c3d096f docs(01-23): complete locale reconciliation plan 2026-07-29 15:13:04 +02:00
Marc-André Bombeck b41b32d472 feat(i18n): complete v1.34.1 locale reconciliation 2026-07-29 15:11:40 +02:00
Marc-André Bombeck 163aa73e01 feat(i18n): reconcile English German and Spanish copy 2026-07-29 15:08:50 +02:00
Marc-André Bombeck 67eeefd584 fix(i18n): translate final security and import states 2026-07-29 15:07:35 +02:00
Marc-André Bombeck b6dfe62c9d test(i18n): inventory v1.34.1 locale contracts 2026-07-29 15:07:05 +02:00
Marc-André Bombeck d34d781d78 docs(01-22): record merged green outcome gate 2026-07-29 15:05:38 +02:00
Marc-André Bombeck 1dcadcb0f0 docs(01-21): complete Google sync fairness plan
- record atomic task commits and verification evidence
- retain GH-01 and GH-06 as live-account evidence gates
2026-07-29 15:04:53 +02:00
Marc-André Bombeck 5bc213c5b8 test(01-21): type guarded progress mock calls
- declare the updateMany mock argument tuple
- assert the guarded write exists before inspection
2026-07-29 15:04:08 +02:00
Marc-André Bombeck ba6d06be3b feat(01-21): expose Google sync terminal status
- return redacted per-resource outcomes from manual sync
- poll owner-scoped progress and invalidate committed workout views
2026-07-29 15:01:13 +02:00
Marc-André Bombeck f3008c9ee0 docs(01-26): complete workout pulse plan 2026-07-29 15:00:38 +02:00
Marc-André Bombeck 104a058096 style(workouts): format pulse enrichment 2026-07-29 14:58:46 +02:00
Marc-André Bombeck 5ec1a3836f feat(01-21): add fair Google sync outcomes
- finish workout and low-cost resources before dense metrics
- report bounded terminal outcomes and invalidate workout caches
2026-07-29 14:58:40 +02:00
Marc-André Bombeck f6555d471b fix(workouts): show aggregate pulse in lists 2026-07-29 14:58:25 +02:00
Marc-André Bombeck d2f6bf33eb fix(workouts): sanitize WHOOP zone enrichment 2026-07-29 14:58:24 +02:00
Marc-André Bombeck 89fefce7ac test(workouts): separate pulse aggregates from curves 2026-07-29 14:57:42 +02:00
Marc-André Bombeck 43cce08012 fix(workouts): share enriched canonical detail data 2026-07-29 14:56:59 +02:00
Marc-André Bombeck ed63a3bf32 test(workouts): pin enriched list and detail envelopes 2026-07-29 14:56:21 +02:00
Marc-André Bombeck bcef9cf51d docs(01-22): complete truthful cohort outcomes plan
- summarize bounded outcome facts and provider verdict folding
- record adjacent Google core verification dependency
2026-07-29 14:55:43 +02:00
Marc-André Bombeck 635e9b64c5 fix(workouts): enrich canonical WHOOP aggregates 2026-07-29 14:55:11 +02:00
Marc-André Bombeck 1cb0f7dd6a test(workouts): pin WHOOP aggregate enrichment 2026-07-29 14:53:59 +02:00
Marc-André Bombeck c3ffc43335 refactor(01-22): reuse Google cohort concurrency limit
- preserve the shared Google Health poll fan-out width
2026-07-29 14:53:22 +02:00
Marc-André Bombeck 6a3861122c feat(01-22): publish truthful cohort outcomes
- fold complete partial failed parked and skipped user verdicts
- bound serialized job facts to a privacy-safe scalar allowlist
- preserve provider retry isolation and reminder queue completion
2026-07-29 14:50:30 +02:00
Marc-André Bombeck a4f00f16f3 docs(01-19): complete Apple Health ECG import plan
- record bounded encrypted import behavior and verification results
- document the counter edge-case deviation and concurrent typecheck issue
2026-07-29 14:49:51 +02:00
Marc-André Bombeck 5cfcb69ef2 feat(01-21): add guarded Google sync progress
- persist one bounded owner-scoped current-run envelope
- reject stale writers and materialize interrupted runs safely
2026-07-29 14:49:12 +02:00
Marc-André Bombeck addd6b7000 fix(01-19): avoid phantom ECG failure counts
- increment archive-level failures only for discovered ECG artifacts
2026-07-29 14:47:47 +02:00
Marc-André Bombeck 4b64556b38 feat(01-19): expose privacy-safe ECG import outcomes
- return and render bounded ECG result counters with partial-failure wording
- document archive limits, encrypted storage, and exact WHOOP constraints
2026-07-29 14:47:22 +02:00
Marc-André Bombeck a686c7200b feat(01-19): persist Apple Health ECG recordings
- encrypt and upsert stable content-addressed ECG recordings transactionally
- keep XML imports successful while ECG members fail with scalar counters
2026-07-29 14:45:40 +02:00
Marc-André Bombeck 8009c251a6 docs(01-15): complete performance and audit baseline plan
- summarize deterministic workload and bundle evidence
- record reconciled audit decisions, deviations, and self-check
2026-07-29 14:44:51 +02:00
Marc-André Bombeck 4ba4ee38cd test(01-15): reconcile audit finding ledger
- deduplicate research, correctness, live, and performance findings
- enforce required evidence fields and privacy-safe report content
2026-07-29 14:43:04 +02:00
Marc-André Bombeck 92f3b03ac0 docs(01-20): complete assessment state contract plan 2026-07-29 14:42:55 +02:00
Marc-André Bombeck f06137c0db feat(01-19): add bounded Apple Health ECG parsing
- validate ZIP paths, compression metadata, and actual decompressed bytes
- normalize ECG CSV descriptors and bounded microvolt samples
2026-07-29 14:42:18 +02:00
Marc-André Bombeck 0bf3a3573d fix(01-20): render explicit assessment states
- prioritize safe fallback text over provider metadata
- keep terminal and pending card geometry stable
2026-07-29 14:40:55 +02:00
Marc-André Bombeck b92b3e4261 docs(01-17): complete navigation stability plan
- record green component and responsive browser evidence
- document preserved authorization safe-area and scanner boundaries
2026-07-29 14:39:39 +02:00
Marc-André Bombeck 33a7dafec4 fix(01-20): terminate assessment polling honestly
- normalize legacy responses into explicit assessment states
- abort on timeout or unmount and expose poll exhaustion
2026-07-29 14:39:38 +02:00
Marc-André Bombeck eccacd91ed test(01-15): record release bundle evidence
- pin build and benchmark results to one isolated commit
- record gzip budgets, headroom, and unresolved route hypotheses
2026-07-29 14:39:00 +02:00
Marc-André Bombeck b8260474ed fix(01-17): remove duplicate mobile insights spacer
- suppress only the inherited mobile pre-pill top padding
- preserve safe-area clearance focus paint and 44px targets
2026-07-29 14:38:19 +02:00
Marc-André Bombeck f3c5ded687 docs(01-18): complete occurrence identity implementation
- document reminder and rolling due fixes across shared consumers
- record green unit and real PostgreSQL regression coverage
2026-07-29 14:38:05 +02:00
Marc-André Bombeck efe203212a fix(01-20): classify safe assessment outcomes
- add a discriminated generated and fallback contract
- screen derived prose and expose retryable cache policy
2026-07-29 14:36:29 +02:00
Marc-André Bombeck 0ae9165aae refactor(01-18): format occurrence identity changes
- apply repository formatting to reminder and scheduling updates
- preserve verified occurrence behavior without semantic changes
2026-07-29 14:36:21 +02:00
Marc-André Bombeck 23c738efac fix(01-17): align and pin desktop sidebars
- place both sidebars on the heading row with one main scroll owner
- bound short-viewport sidebar content without horizontal layout drift
2026-07-29 14:36:18 +02:00
Marc-André Bombeck 840e47fda5 fix(01-18): preserve authoritative rolling due in consumers
- expose the canonical due schedule identity from the shared list read
- prevent passed local windows from rebinding a prior overdue occurrence
2026-07-29 14:35:23 +02:00
Marc-André Bombeck 2de9bccf9d fix(01-18): retain rolling occurrence through due band
- preserve prior rolling identity independently of local-day flooring
- scope exact resolution by schedule and era while advancing resolved grids
- include open rolling slots for display without widening compliance bands
2026-07-29 14:33:17 +02:00
Marc-André Bombeck d0fbdd388d docs(01-16): complete presentation and records cleanup plan 2026-07-29 14:31:17 +02:00
Marc-André Bombeck c0321d2f27 test(01-15): add bounded performance fixture baseline
- benchmark observed Google, reminder, and score workload shapes
- record local cold and warm timing without production access
2026-07-29 14:31:07 +02:00
Marc-André Bombeck c0af97ce46 fix(01-18): preserve reminder occurrence across midnight
- select active canonical recurrence slots instead of rebuilding from tick day
- scope intake suppression and dedup metadata to the occurrence local date
2026-07-29 14:30:23 +02:00
Marc-André Bombeck 7396119ec8 feat(01-17): group gated admin navigation
- cluster allowed destinations under semantic settings-style headings
- preserve fail-closed role gating and mobile target order
2026-07-29 14:29:51 +02:00
Marc-André Bombeck 431120d55e fix(01-16): unify records action grammar
- use Save for new and existing lifestyle facts
- show first-entry or populated-list add actions without duplication
2026-07-29 14:29:19 +02:00
Marc-André Bombeck c4ec2d552f fix(01-16): normalize assessment prose presentation
- use the readable foreground at confirmed medication and Mood mounts
- tighten Health Score and medication assessment spacing
2026-07-29 14:28:47 +02:00
Marc-André Bombeck d67b899138 fix(01-16): remove redundant score and summary presentation
- keep useful Today prose without repeating the ring score
- remove stored-summary regeneration and Health Score notice/method/list copy
2026-07-29 14:27:45 +02:00
Marc-André Bombeck b39ddeb5b5 docs(01-14): complete cohort outcome contract plan
- record expected RED provider fold and serializer evidence
- preserve scope and concurrent-work boundaries
2026-07-29 14:27:15 +02:00
Marc-André Bombeck 966a869630 docs(01-11): complete medication occurrence contract plan
- document independent reminder and rolling-overdue RED failures
- record passing persistence, retry, and isolation controls
2026-07-29 14:26:47 +02:00
Marc-André Bombeck 690c4aeb5a test(01-14): add failing truthful cohort contracts
- distinguish complete partial failed parked skipped and clean-zero outcomes
- require bounded privacy-safe scalar serialization and retry codes
2026-07-29 14:26:26 +02:00
Marc-André Bombeck 34372b57bd fix(01-11): use canonical compliance status fixture
- model resolved rolling doses with the valid upcoming status
- keep take-all suppression aligned with production DoseStatus values
2026-07-29 14:25:06 +02:00
Marc-André Bombeck 35b172c6f5 docs(01-10): complete UI contract plan
- document atomic RED contract commits and verification evidence
- record production handoff for presentation and navigation fixes
2026-07-29 14:24:25 +02:00
Marc-André Bombeck 19590cd216 docs(01-13): complete Google sync contract plan
- record expected-red and positive verification evidence
- preserve reporter-gated cause and immutable scanner boundaries
2026-07-29 14:24:16 +02:00
Marc-André Bombeck 958b0e10d9 fix(01-11): keep reminder fixture type-safe
- mutate the inferred schedule fixture without widening nullable fields
- preserve the cross-midnight RED expectation unchanged
2026-07-29 14:24:00 +02:00
Marc-André Bombeck 25612294d1 test(01-11): pin rolling overdue consumer parity
- compare persisted weekly occurrence across list, dashboard, GLP-1, and take-all
- protect exact intake retries and unrelated occurrence isolation
2026-07-29 14:23:52 +02:00
Marc-André Bombeck 65ca1aae42 test(01-10): add red responsive navigation contracts
- pin grouped and heading-aligned Settings and Admin sidebars
- add mobile Insights geometry and deterministic release evidence captures
2026-07-29 14:22:54 +02:00
Marc-André Bombeck 6f84629482 test(01-13): add red Google sync terminal contracts
- cover authenticated status polling and bounded resource outcomes
- prove committed workouts invalidate server and client cache surfaces
2026-07-29 14:22:49 +02:00
Marc-André Bombeck b8766fa93b docs(01-12): complete Apple ECG contract plan
- record expected RED archive and PostgreSQL boundaries
- preserve green WHOOP and source-agnostic regression evidence
2026-07-29 14:22:08 +02:00
Marc-André Bombeck 6cc3de8248 test(01-12): add failing Apple ECG import contracts
- pin bounded path-safe archive and parser behavior
- require encrypted idempotent fail-soft persistence and WHOOP regressions
2026-07-29 14:20:56 +02:00
Marc-André Bombeck d5f473d672 test(01-11): specify rolling weekly overdue identity
- pin the prior-day Mounjaro occurrence across local midnight and DST
- cover resolution state, band end, schedule siblings, and era isolation
2026-07-29 14:19:03 +02:00
Marc-André Bombeck 18d0facdfe test(01-10): add red assessment and records contracts
- pin honest assessment fallback, exhaustion, foreground, and state envelopes
- require consistent save and empty-state add action grammar
2026-07-29 14:18:35 +02:00
Marc-André Bombeck d8e52b831d test(01-13): add red Google sync progress contracts
- pin workout-first fairness and bounded resource outcomes
- require owner-scoped race-safe current-run progress in PostgreSQL
2026-07-29 14:18:20 +02:00
Marc-André Bombeck 81893ac972 test(01-11): reproduce cross-midnight reminder identity bug
- pin prior-day occurrence lookup across timezone and DST boundaries
- prove real worker dedup and distinct next-dose behavior with PostgreSQL
2026-07-29 14:15:48 +02:00
Marc-André Bombeck 8e944dbcd3 test(01-10): add red dashboard and score presentation contracts
- preserve useful Today narrative without duplicate score prose
- remove stored-document regeneration from the detail surface
- retain score identity while rejecting redundant method copy
2026-07-29 14:12:58 +02:00
Marc-André Bombeck 44b5d8da17 docs(01-09): complete focused security gate plan
- summarize zero-skip final-boundary security evidence
- record operator contracts, scope freeze, and command correction
2026-07-29 14:12:52 +02:00
Marc-André Bombeck 56c20f26db docs(01-09): record corrected green security gate
- adopt revised direct Vitest commands as authoritative evidence
- retain the obsolete wrapper failure as an audit trail
2026-07-29 14:11:41 +02:00
Marc-André Bombeck fb95e3a134 docs(01-09): freeze post-security implementation scope
- attest immutable scanner and package baseline equality
- defer Low findings, dependency work, and final scan to later gates
2026-07-29 14:10:14 +02:00
Marc-André Bombeck 2409183588 test(01-09): record focused security gate evidence
- map SEC-01 through SEC-08 to positive and attacker boundaries
- record green focused suites and the literal command blocker
2026-07-29 14:09:30 +02:00
Marc-André Bombeck 9ea0cb867f docs(01-09): document security contract changes
- define explicit MCP read scope boundaries and upgrade handling
- document Nightscout private-origin pinning and reset revocation
2026-07-29 14:08:37 +02:00
Marc-André Bombeck ddbfcb4588 docs(01-07): complete SSRF policy plan
- record exact-origin and IPv6 transition enforcement
- capture verification results and the scoped sync-route deviation
2026-07-29 13:59:35 +02:00
Marc-André Bombeck 5b1e11d5af docs(01-08): complete passkey and recovery plan
- record session-bound enrollment and atomic reset outcomes
- capture verification evidence, decisions, and blocking type correction
2026-07-29 13:58:00 +02:00
Marc-André Bombeck 27a3e08436 fix(01-07): make private Nightscout access operator-owned
- remove the user-facing private-host bypass
- render a redacted operator-action state for denied legacy connections
2026-07-29 13:57:48 +02:00
Marc-André Bombeck f95da50ba0 fix(01-07): enforce exact Nightscout origins
- move private-host authority to the server-owned exact-origin policy
- preserve stable redacted policy failures through connect and sync
2026-07-29 13:57:07 +02:00
Marc-André Bombeck b7b18a6da2 fix(01-08): align passkey request fixture typing
- construct NextRequest options with the framework-compatible init type
- preserve every enrollment security assertion unchanged
2026-07-29 13:55:57 +02:00
Marc-André Bombeck 9c661dcc5e fix(01-08): revoke credentials atomically on password reset
- lock the resolved account and rotate its password in one transaction
- revoke sessions, tokens, trusted devices, and elevations with rollback safety
2026-07-29 13:55:11 +02:00
Marc-André Bombeck 7874eea04d feat(01-08): require reauthentication before passkey enrollment
- offer password, TOTP, passkey, and security-key proof choices
- start registration only after the selected existing factor succeeds
2026-07-29 13:53:59 +02:00
Marc-André Bombeck 89c83510d4 feat(01-08): bind passkey enrollment to fresh cookie proof
- require an existing-factor proof before issuing registration options
- atomically claim session-bound challenges before credential persistence
2026-07-29 13:52:22 +02:00
Marc-André Bombeck 9cb11abedb docs(01-05): complete MCP security enforcement plan
- record green scope and PostgreSQL replay-family gates
- document transactional rotation and post-commit artifact semantics
2026-07-29 13:52:03 +02:00
Marc-André Bombeck db08f6c175 fix(01-05): serialize MCP refresh families
- lock and rotate refresh families inside one database transaction
- revoke CAS losers and insert successors before commit
- sign refresh artifacts only after committed state
2026-07-29 13:50:14 +02:00
Marc-André Bombeck a177869609 fix(01-07): classify embedded IPv4 destinations
- Normalize IPv6 literals before transition-prefix inspection
- Reject mapped, compatible, 6to4, and NAT64 private embeddings
- Share the verdict with pinned public and operator dispatchers
2026-07-29 13:49:49 +02:00
Marc-André Bombeck 36cb64c840 test(01-02): exercise DNS transition cases past fetch port guard
- replace blocked port 9 with non-blocked test port 8089
- preserve pre-connect ENOTFOUND assertions for all transition forms
2026-07-29 13:49:46 +02:00
Marc-André Bombeck 20a6731c8b fix(01-05): enforce MCP health read scope
- require health:read or wildcard for MCP read capability
- reject insufficient scopes before downstream gates and server construction
2026-07-29 13:47:46 +02:00
Marc-André Bombeck 8ca6ebd773 docs(01-06): complete PWA cache isolation plan
- document public-only install and data-free offline behavior
- record scoped current and legacy cache eviction
- capture green final-boundary and session-hook verification
2026-07-29 13:47:17 +02:00
Marc-André Bombeck 338dbe6c1f fix(01-06): purge owned PWA caches on session end
- delete current and legacy HealthLog static page and data caches
- preserve unrelated CacheStorage namespaces during activation and logout
- keep IndexedDB query-cache removal in the session-end boundary
2026-07-29 13:46:15 +02:00
Marc-André Bombeck 31248ec273 docs(01-01): complete MCP security red contracts plan
- record expected-red authorization and refresh-race evidence
- hand off exact enforcement targets to Plan 01-05
2026-07-29 13:45:42 +02:00
Marc-André Bombeck 8851569658 fix(01-06): isolate PWA install from authenticated root
- remove the dashboard root from immutable precaching
- evict same-version root entries left by older workers
- retain public logos and favicon for offline support
2026-07-29 13:45:23 +02:00
Marc-André Bombeck 0c29e6e6b0 test(01-01): keep bearer regression lint-clean
- avoid React hook-name classification for token fixture helpers
2026-07-29 13:44:28 +02:00
Marc-André Bombeck 8b5e928efa docs(01-03): complete credential security contracts plan
- document passkey enrollment and reset recovery RED boundaries
- record focused expected failures and green parity evidence
- preserve production, schema and scanner-owned files untouched
2026-07-29 13:43:49 +02:00
Marc-André Bombeck 51f5f43957 test(01-01): preserve explicit transaction contract
- keep the red transaction-client call aligned with the planned signature
- isolate the concurrent documentation commit from MCP test content
2026-07-29 13:43:27 +02:00
Marc-André Bombeck 390c718b74 test(01-01): reproduce MCP refresh replay race
- coordinate concurrent exchanges with a real PostgreSQL row-lock barrier
- verify family-wide successor revocation and cross-connection isolation
2026-07-29 13:42:30 +02:00
Marc-André Bombeck cc8567d82f docs(01-02): complete PWA and SSRF red contracts plan
- document expected cache-isolation and transition-form failures
- record preserved public and redirect behavior
- capture atomic task commits and verification evidence
2026-07-29 13:42:30 +02:00
Marc-André Bombeck abea48026b test(01-03): add atomic reset revocation contracts
- execute the operator CLI against real PostgreSQL credential tables
- require complete target revocation and unrelated-admin isolation
- force a mid-transaction database failure to prove rollback
2026-07-29 13:42:04 +02:00
Marc-André Bombeck eb5cd5fbcb docs(01-04): complete Nightscout security contract plan
Tasks completed: 3/3
- Specify exact operator-origin configuration
- Pin connect-time and sync-time egress policy
- Pin the user-facing breaking-change state
2026-07-29 13:41:52 +02:00
Marc-André Bombeck 38501f6574 test(01-01): specify transactional refresh rotation
- require one transaction for rotation and successor insertion
- pin CAS-loss revocation and insertion rollback semantics
2026-07-29 13:40:03 +02:00
Marc-André Bombeck 3f4afb26a4 test(01-03): pin passkey enrollment reauthentication UX
- require password, passkey, TOTP and WebAuthn proof choices
- order fresh-factor completion before challenge creation
- keep successful enrollment in one cookie-bound ceremony
2026-07-29 13:39:55 +02:00
Marc-André Bombeck e25080b214 test(01-02): add red DNS-pinned SSRF contracts
- refuse standard embedded-private IPv6 resolver answers pre-connect
- preserve public IPv6 to IPv4 fallback connectivity
- prove followed redirects repeat the pinned DNS verdict
2026-07-29 13:39:52 +02:00
Marc-André Bombeck 5569b8259b test(01-04): pin Nightscout operator-action UI
- Remove the user-facing private-host bypass contract
- Require redacted operator approval guidance for legacy failures
- Preserve usable public and approved-private connected states
2026-07-29 13:39:50 +02:00
Marc-André Bombeck 14f451093e test(01-04): pin Nightscout egress enforcement
- Deny user-authorized and mismatched private origins before network access
- Preserve exact operator-private and public positive flows
- Require stable redacted policy reasons through sync boundaries
2026-07-29 13:39:06 +02:00
Marc-André Bombeck 9f6cd86c0d test(01-03): add passkey enrollment security contracts
- require fresh existing-factor proof on both cookie-only legs
- bind registration challenges to user, purpose, session, expiry and one use
- cover bearer, ambient-cookie, stale, foreign and replay attacks
2026-07-29 13:38:53 +02:00
Marc-André Bombeck 6c0f47acad test(01-01): pin MCP read scope boundaries
- cover health read and wildcard positive capability paths
- reject unrelated scopes before server registration and database reads
2026-07-29 13:38:41 +02:00
Marc-André Bombeck fb533147d3 test(01-02): add red IPv6 transition classifier matrix
- cover mapped compatible 6to4 and NAT64 private embeddings
- exercise both literal URL and raw DNS address verdicts
- preserve public IPv4 native IPv6 and transition forms
2026-07-29 13:38:11 +02:00
Marc-André Bombeck 3a813b51bd test(01-02): add red PWA cache isolation contracts
- exercise signed-in service-worker install at CacheStorage boundary
- require data-free offline navigation and immutable public assets
- require current and legacy session cache cleanup
2026-07-29 13:37:20 +02:00
Marc-André Bombeck 668f85a58d test(01-04): specify exact Nightscout origin policy
- Parse canonical server-owned private origins
- Reject malformed and non-exact trust grants
- Preserve public and approved-private positive contracts
2026-07-29 13:36:46 +02:00
Marc-André Bombeck 6528d9f289 Rewrite the v1.34.0 dedication
Name everyone who opened an issue, not just the three tied to a
specific fix. Drop the sponsorship amount; it was never the point
and stays private.
2026-07-29 00:42:50 +02:00
Marc-André Bombeck b4d218f511 Merge pull request #663 from MBombeck/release/v1.34.0
v1.34.0
2026-07-28 23:39:33 +02:00
Marc-André Bombeck e7c6610c63 fix(e2e): re-target compare-to tap-target check at the per-chart overlay popover 2026-07-28 23:24:53 +02:00
Marc-André Bombeck 1d784c6367 fix(analytics,ci): merge health-score meta annotations, migrate integration tests, remove dead exports, raise bundle budget 2026-07-28 23:02:00 +02:00
Marc-André Bombeck 668e3ca6b6 fix(insights,settings): restore hero-strip column width, gate hero fieldset on hydration 2026-07-28 22:03:39 +02:00
Marc-André Bombeck 41a18813d1 chore(release): sync service-worker version fallback to 1.34.0 2026-07-28 21:50:22 +02:00
Marc-André Bombeck 17e09345d2 chore(release): v1.34.0 — a failure looks like a failure, a number is yours or it says so 2026-07-28 21:49:56 +02:00
Marc-André Bombeck 2bab06e323 feat(medications): resolve each dose slot's own window duration 2026-07-28 21:26:53 +02:00
Marc-André Bombeck ff8ead2db9 fix(analytics): repair route/i18n/guard fallout from health-score merge 2026-07-28 21:19:18 +02:00
Marc-André Bombeck 7b8fb98e48 feat(analytics): replace health score with reference composite 2026-07-28 21:12:05 +02:00
Marc-André Bombeck 93e1b83779 test(api): guard response field consumers 2026-07-28 21:11:56 +02:00
Marc-André Bombeck d9880b0562 test: cover every measurement source write path 2026-07-28 21:11:50 +02:00
Marc-André Bombeck 3aff2c16db fix(insights): scope generation guard to prompt fields, clear cache on restore 2026-07-28 21:11:43 +02:00
Marc-André Bombeck c91558f18f feat(analytics): replace health score with reference composite 2026-07-28 21:10:15 +02:00
Marc-André Bombeck 87544af2cd Fix medication reminder slot attribution 2026-07-28 21:06:07 +02:00
Marc-André Bombeck af47bae046 test(api): guard response field consumers 2026-07-28 21:05:42 +02:00
Marc-André Bombeck f201fb32e7 test: cover every measurement source write path 2026-07-28 15:12:23 +02:00
Marc-André Bombeck 666a85d311 feat(insights): persist correlation patterns 2026-07-28 14:01:11 +02:00
Marc-André Bombeck 8ece6f39cc fix(status): expose background failures 2026-07-28 14:00:44 +02:00
Mathew Sims 08e535a0d9 fix(reminders): resolve each dose slot's own window duration, not the whole schedule's span
The 15-minute reminder tick computed windowDuration ONCE per schedule
as windowEnd - windowStart, then applied that SAME span to every
timeOfDay slot in the schedule. For a single-dose schedule that span
genuinely is the one slot's window, but for a multi-dose schedule
(e.g. windowStart=08:00/windowEnd=18:00 with timesOfDay=[08:00,18:00])
it silently gave the 08:00 slot a 600-minute grace before even
entering ORANGE phase — ten hours nobody configured — while an
identically-intended single-dose schedule with a narrow legacy span
(08:00-09:00) correctly escalated on schedule but then had only a
30-minute radius for a logged-but-late dose to suppress its reminder.
Same misapplied field, opposite-looking symptoms: one medication
silently never reminded, the other kept escalating despite being
logged as taken.

resolveSlotWindowDurationMinutes resolves each slot's duration
independently, checked in priority order: an explicit per-dose
doseWindows entry for that exact timeOfDay (the actual per-dose rule
set in the dose-window editor) > the schedule's reminderGraceMinutes
(documented for exactly this purpose but never actually read by the
reminder tick until now — the write path already reads it via
graceToleranceMs) > the same default an unconfigured "point" dose
gets everywhere else in the app, bounded by the gap to the nearest
sibling slot so two doses can never share a window. A single-dose
schedule with neither override keeps the exact legacy windowEnd -
windowStart behaviour, unchanged, for backward compatibility with
schedules that predate the per-dose doseWindows system.

The core property this restores: a dose's resolved window never
depends on how many sibling doses share its schedule. Two
identically-configured doses now resolve identically regardless of
whether one of them happens to have a second dose elsewhere in the
day.

Full suite (18k+ tests) passes with no regressions; 13 new tests
cover both real-world cases that surfaced this (a single 08:00-09:00
schedule and a twice-daily 08:00/18:00 one) plus the sibling-
independence property directly.
2026-07-28 12:55:34 +01:00
Marc-André Bombeck a689329e79 feat(dashboard): configure Today highlights 2026-07-28 13:22:08 +02:00
Marc-André Bombeck a2f2a30ac2 fix(quality): close backup and localization guard gaps 2026-07-28 12:03:02 +02:00
Marc-André Bombeck 477485ba47 chore(repo): preserve local workspace exclusions 2026-07-28 12:02:54 +02:00
Marc-André Bombeck 4e97dbff16 chore(repo): keep local workspace files untracked 2026-07-28 11:49:23 +02:00
Marc-André Bombeck 81825ce23b merge: forward-port v1.33.2 into release/v1.34.0 2026-07-28 11:43:44 +02:00
Marc-André Bombeck 2058321007 docs(agents): point the agent entry file at the manual instead of copying it
AGENTS.md duplicated the project description, version, stack table and a tree
of the source layout, then went untouched for about thirty releases. It
announced v1.4.0 as current and called the UI Dracula-themed, both false for a
long time. An instruction file that is confidently wrong is worse than none.

Replace the copies with a pointer to CLAUDE.md and keep only what is genuinely
specific to running as an agent here: the generated-client read ban, the gate
including TZ=UTC, reading output rather than exit codes, where working notes
live, and how the worktrees are used.
2026-07-28 11:10:24 +02:00
Marc-André Bombeck 592705550f Merge pull request #661 from MBombeck/fix/v1.33.2-restore-cycle-symptoms
v1.33.2 — restore the cycle symptoms v1.33.1 said it had fixed
2026-07-28 00:23:40 +02:00
Marc-André Bombeck 4d459dbb68 chore(release): v1.33.2 — the cycle-symptom fix that did not reach the file
v1.33.1 claimed custom cycle symptoms were carried in backups. They were not,
and the same release turned the old silent drop into a hard failure, so the
accounts the fix was written for went from losing one symptom link to being
unable to restore at all.
2026-07-28 00:09:29 +02:00
Marc-André Bombeck dd3a7915d6 test(backup): cover the assembly a custom cycle symptom has to cross
Both ends of this defect had passing tests for the whole time it was broken.
The section builder returned the symptoms, the wire schema declared them, the
restore read them, and each of those three was covered on its own. Nothing
covered the object literal in between, which copied section keys out one at a
time and never listed `customSymptoms`.

So the test spans the seam and asserts on rows. It populates an account with a
symptom of its own on a day-log, builds the file through the real
`buildFullBackupPayload`, serialises it, parses it back through the real
`parseBackupPayload`, and drives the real admin restore. Asserting that the
payload carries the field would only re-test the writer that was already green.

Deleting the symptom row before the restore is the case the field exists for,
not a contrivance: `restoreCycleData` clears day-logs, cycles, and the profile
but never the symptom catalogue, so a restore onto the same instance still
finds the row sitting there. That is why the older round-trip test stayed green
through the whole thing.

Mutation check: hand-pick the cycle section's keys again and the restore
answers 500 on `Unknown cycle symptom keys: custom:sacroiliac-ache`, which is
the production failure verbatim.
2026-07-28 00:09:22 +02:00
Marc-André Bombeck ddbce306be fix(backup): drop the manifest key the records spread already carries
The assembly picked `manifest` out of the records section by hand, one line
under the comment explaining that both sections are spread precisely so nobody
has to pick their keys. It resolved to the same value the spread had already
placed, at the same position, so the emitted file is byte-identical either way.

Left in, it is the pattern that lost the custom cycle symptoms sitting next to
the note about how that happened.
2026-07-28 00:09:10 +02:00
Marc-André Bombeck dddd2164b1 test(withings): teach the fallback mock the shape its function now returns
The sync function was widened to answer with what it wrote rather than a bare
count. This mock still resolved to a number, so the handler destructured
`imported` off it, got undefined, and reported `measurements_imported: NaN`.

Nothing in the type system could see it: `vi.fn().mockResolvedValue(0)` carries
no relation to the real signature, so a widened contract drifts past every mock
in the tree unnoticed. This was the only sibling; the class is worth a sweep of
its own.
2026-07-28 00:07:53 +02:00
Marc-André Bombeck 51dbfefbcb fix(backup): carry the intraday day shape through export and restore
`IntradayCumulativeProfile` shipped classified as backed up and carried by
nothing. The drain folds the per-sample rows into twenty-four running totals
and deletes the originals in the same transaction, so past the grace window
the table is the only copy of a day's curve: a restore rebuilt the account
without it, reported success, and left the same-time baseline reading "still
learning" for a fortnight.

Builder and restore sit together in one file, the way the profile and cycle
pairs do, because the restore route delegates and a grep of the route alone
lies. The section spreads into the payload rather than being copied key by
key. Two curves for the same metric and day, or a curve that is not a whole
day, throw naming the day instead of writing a row every reader drops.

The pair is proven end to end rather than at each end: the round-trip suite
builds a real payload from a populated account, serialises it, parses it back
through the real parser, drives the real restore, and asserts on the rows
written. Remove the read and it goes red.
2026-07-28 00:07:10 +02:00
Marc-André Bombeck 9d1f21408f fix(insights): floor the same-time band from the metric, not by hand
Two parallel lines of work reached the same conclusion independently: a
baseline band over a quantity that cannot go negative must not report a lower
edge below zero. One of them answered it locally with a `Math.max(0, …)` at the
call site; the other gave every metric's numeric domain a single home and made
`buildBaselineBand` take the metric so the floor comes from the declaration.

Keep the second and drop the first. The required parameter is what surfaced
this at all: the new call site could not compile without naming its metric,
which is the whole reason it was made required rather than defaulted.
2026-07-27 23:45:00 +02:00
Marc-André Bombeck 2aeb925e28 merge: s15-stepsdisplay into release/v1.34.0 2026-07-27 23:41:34 +02:00
Marc-André Bombeck 66017a8b06 merge: s10-google into release/v1.34.0 2026-07-27 23:41:34 +02:00
Marc-André Bombeck b14943ea1b merge: s8-baselines into release/v1.34.0 2026-07-27 23:41:34 +02:00
Marc-André Bombeck fbdadae0ce merge: s5-export into release/v1.34.0 2026-07-27 23:41:33 +02:00
Marc-André Bombeck b402be221d merge: s4-yardstick into release/v1.34.0 2026-07-27 23:41:33 +02:00
Marc-André Bombeck 602ca36ee2 merge: s3-written into release/v1.34.0 2026-07-27 23:41:33 +02:00
Marc-André Bombeck 924babe2ea merge: s1-jobspine into release/v1.34.0 2026-07-27 23:41:33 +02:00
Marc-André Bombeck e5b958cd86 fix(backup): carry the account's own cycle symptoms into the payload
The section builder returned them, the wire schema declared them, and the
restore has read them since v1.33.1 — including the throw that replaced the
silent drop. The payload assembly copied its keys out one by one and was never
extended, so every backup written carried an empty list. An account with a
custom symptom did not get the silent drop that release removed; it got a
restore that failed outright on a key nothing had written back.

Spread the cycle and records sections instead of hand-picking their keys, so a
field added to either section interface reaches the wire by construction. Declare
labelEncrypted on the cycle section, which the builder already emitted.
2026-07-27 23:40:44 +02:00
Marc-André Bombeck b56f5721a2 test(measurements): pin that the day's shape is written before the delete
The drain's existing transaction mock had no profile delegate, so the fold
could have thrown inside the per-day error boundary and every assertion would
still have passed. Cover the merge directly: the upsert lands before the
deleteMany, the stored curve matches where the samples actually fell, and a
serialised tick that finds the rows already drained writes nothing.
2026-07-27 23:39:37 +02:00
Marc-André Bombeck 6bd8d387ae feat(insights): surface the same-time baseline on Today and in the grid
The rail gains a same_time_baseline kind and a server builder, which is the
growth path that type documents for itself. It speaks only when today is
actually outside the typical band: a card that reports doing exactly what you
usually do teaches people to stop reading the rail. Dismissing it is keyed by
day and metric, not by the hour, so it stays dismissed instead of coming back
every sixty minutes.

The Insights grid gains a section of four tiles, one per cumulative metric,
each honestly quiet until the history accrues.

The two-trajectory chart it needs did not exist, so DeltaSparkline gains a
second mode rather than a second chart family: the comparison curve rides the
same container, the same fixed height and the same shared y-domain as a dashed
muted line behind the live one, which is the only way the two can be read
against each other.

Six locales. The wipe and backup plans learn about the new table: the profile
cannot be rebuilt once the samples are gone, so it is carried, not derived.
2026-07-27 23:32:22 +02:00
Marc-André Bombeck 1f47db5955 docs(jobs): name the binding contract the registrars actually use 2026-07-27 23:29:20 +02:00
Marc-André Bombeck e69e0b3941 fix(admin): count every record class in the upload summary
The total shown after a backup upload skipped the nutrient day totals, so a file
that carried and restored them reported fewer records than it held. Same defect
as the payload gaps this branch closes, on the reporting side. Counts the
nutrient days, the health profile and the custom metrics with their readings.
2026-07-27 23:27:18 +02:00
Marc-André Bombeck a7fcd6d249 fix(targets): show the weight target the user set, not the WHO band
The targets reference panel graded weight against the WHO BMI band even
for an account that had entered its own target, so the panel under the
weight chart contradicted the chart above it. An override now wins and
the card's source reads Custom, the same posture the glucose builder has
always taken with a user-pinned band. The BMI classification stays
WHO-derived: naming which category a reading falls in is a reference
fact, not a goal.
2026-07-27 23:27:14 +02:00
Marc-André Bombeck ae294573ef test(jobs): follow the queue guards onto the new binding
Thirty-three suites read a registrar as source text and matched
`boss.work` next to a queue constant. They now match `createAndWork`,
and the seven schedule tuples that grew a `cronIsTheRetry` third element
assert that element rather than accepting any arity — a guard that
accepts more than it did before is a guard that notices less.

Two negative assertions were tightened while they were open: the
"has no cron" checks on the arrival and workout-insight queues ended at
a literal `]`, so a cron added WITH send options would have slipped past.

The behavioural repairs carry the real change. Handlers that resolved to
undefined now resolve to their outcome, so the assertions name the counts
instead of asserting nothing. The reconcile tick's test said it swallows
a failure instead of throwing; it reports a failed outcome now, and the
test says that, with the comment pointing at the retryLimit that carries
the old anti-retry-loop concern.
2026-07-27 23:23:53 +02:00
Marc-André Bombeck faa89ef54b test(backup): guard that a backed-up model travels both ways
The classification guard proved every model has an opinion and would have
passed unchanged while a model classified as carried was read into the payload
and never written back — which is what happened to the nutrient day totals, the
custom mood tags and the custom cycle symptoms, three releases running.

Split BACKED_UP into the models with both ends and the models without, each of
the latter carrying what an account loses meanwhile, so the debt is a register
rather than a silence. The check reads declared writer and restore files rather
than grepping for a route, because the route delegates: a review that grepped
only the route concluded the cycle data was never restored and had to be
retracted. Declaring the file list caught its own first omission — measurements
are read through the pager, not directly.

Adds a round trip that builds a real payload, parses it through the wire schema
and drives the real restore, asserting on the rows written rather than on the
payload. Dropping the cycle section spread makes it fail the way production
did: the restore throws on a symptom key nothing wrote back.
2026-07-27 23:19:58 +02:00
Marc-André Bombeck be348f8820 test(score): pin the weight yardstick, the mood gate and rollup/live parity
Covers what the required parameters now make impossible to forget: the
scorer takes the band whole and never widens it, a missing target scores
the bare trend, the resolved yardstick rides the weight component, the
shared builder refuses the reference band that sits under an override,
the snapshot gates its mood pillar on the module map, an explicit target
reaches both the score and the dashboard bands, and weight/BMI record
direction follows the user's own goal.

F1 adds the golden the score has never had: the rollup read branch and
the live read branch scored against one series encoded both ways. On the
canonical one-weigh-in-a-day cadence they agree exactly. On an uneven
cadence they part company, and the test pins that the whole difference is
the per-day mean collapse - the rollup branch equals the live branch fed
those same daily means - with a companion assertion proving the fixture
is not a no-op.
2026-07-27 23:15:38 +02:00
Marc-André Bombeck f0a804a812 test(jobs): pin the outcome spine and the binding rule
runJob's claim is behavioural, not typed: an ok:false outcome has to
reach pg-boss as a failed job, reported once, with the handler's own
facts and the thrown error's stack rather than runJob's frame. That is
what the retention cleanups now rely on, so it is pinned.

The rule's tests carry its blind spots in the valid list, where a reader
will trip over them: an aliased receiver is not detected, and neither is
a handler that returns ok:true after swallowing.
2026-07-27 23:11:38 +02:00
Marc-André Bombeck 232217ce68 test(insights): pin the three step-page display rules
One guard per defect, each verified by breaking it on purpose first.

A non-negative metric's baseline `low` never goes below zero — swept over
every type in the catalogue with a series whose spread exceeds its
median, plus the reported step shape rendering an edge of exactly 0. The
axis assertion sits in the same file, because the point of the fix is
that the two read one fact.

No insights page passes an English word as a unit — the page sources are
scanned for `unit=` / `yAxisUnit=` literals and held against the set of
language-neutral symbols, and every `unitKey` is resolved in all six
locales. That second half matters: a key passed as a prop is invisible to
the `t()` call-site coverage sweep, so nothing else would notice a
missing translation.

An integer-valued metric never renders a fractional part — the step page
is rendered in German with no precision prop at all and must produce no
comma-decimal, on the Min/Max card and on the band sentence below it.

Two types were missing from the plausibility map, so the first guard also
asserts the map covers the enum.
2026-07-27 23:11:37 +02:00
Marc-André Bombeck 0aa5cec17d feat(jobs): bind every queue through createAndWork and ban the bare call
All 104 bindings across the five registrars now go through createAndWork,
whose handler type is (jobs) => Promise<JobOutcome>. The forty-two inline
handlers return what they did. A handler that falls off the end no longer
compiles, and the healthlog/job-handler-outcome rule refuses a bare
boss.work( anywhere under src/lib/jobs except the one module that owns
the wrapper. The rule's header states its own limit: it catches the
omission, not a handler that claims ok:true after a swallow.

Retry policy, reviewed queue by queue because the rethrow changes what
runs, not only what is visible:

  - Twenty-one cron passes carry cronIsTheRetry (retryLimit 0). They are
    the retention DELETEs, the OAuth-state prunes, the hourly maintenance
    sweeps and the nightly per-sample drain — every one of them a
    statement that will fail identically a second later. pg-boss defaults
    to two retries with no delay, so converting them without this would
    have run the same doomed statement three times a night. The next tick
    is the retry and it arrives with the whole gap as backoff.
  - The policy rides on the schedule, not on createQueue: create_queue()
    ends in ON CONFLICT DO NOTHING, so a queue-level default would be
    inert on every already-running instance.
  - Everything else keeps its retries deliberately. A TLS probe, an
    off-host upload, a restore drill and a provider poll all fail
    transiently, and the reminder ticks only fail at job level when the
    pass itself could not run.
2026-07-27 23:08:50 +02:00
Marc-André Bombeck 6911f115bc fix(integrations,labs): let the write decide how a sync result reads
Six integration cards and the lab-OCR dialog keyed their tone off `res.ok`.
A sync that imported nothing because every reading was refused rendered a
green tick and a count, and a re-scan in which every confirmed row turned out
to be a duplicate raised a green toast saying zero readings were saved.

Each surface now renders the outcome the route resolved: a tick only when
everything landed, a warning when some of it did not, an error when none of
it did, and a neutral note when there was nothing new. The lab-OCR toast
picks its variant the same way.

Replaces the directory-scoped import guard with one that covers the whole
component tree and pins per-file occurrence counts, so a listed file cannot
quietly add a second tick. Its docstring states what it cannot catch.
2026-07-27 23:06:44 +02:00
Marc-André Bombeck c6593b292f feat(backup): carry the health profile and custom metrics through export and restore
Both were classified as backed up when the backup plan was written and carried
by nothing. The custom metric is live data loss: a series the account defined
itself is the one kind no integration can re-sync, so a restore rebuilt the
account without the metric and every one of its readings, and reported success.

Builder and restore live in one file beside each other, the way the cycle pair
does — a grep of the restore route alone does not find either, because the route
delegates to both.

Readings nest inside the metric that owns them rather than referencing it by
name, so the restore has no parent to look up and no lookup to miss. A duplicate
metric name and an undecodable ciphertext field both throw and name the value.
Intake events whose medication did not restore now throw with the unresolved
reference instead of being filtered away into a dose history that comes back
short while the restore reports success.
2026-07-27 23:04:44 +02:00
Marc-André Bombeck b2955beffd feat(insights): add the same-time baseline for cumulative metrics
Compare today's running total at the last completed local hour against this
person's own typical total at that same hour, over a trailing four weeks of
stored intraday profiles. Same estimator as the vitals baseline — median for
the centre, median plus or minus k times MAD for the band — so a reader who
understands one understands the other.

It refuses to speak below fourteen usable days and says so through the
existing insufficient arm rather than a band built on three days. It ships no
projected day total: extrapolating a partial day is a guess, and this exists
to replace guesses.

Steps first; active energy, distance and flights ride the same engine.
2026-07-27 23:04:01 +02:00
Marc-André Bombeck f5c676bbeb fix(insights): give a metric's numeric domain one home
Three defects on one card of the step sub-page, all of them a surface
answering a question that belongs to the metric.

"Your usual range is -5,595.6-13,387.6 steps", under a chart whose own
y-axis refuses to draw a negative step. Same app, same question, two
answers: the axis clamped and the sentence beneath it did not. Both now
read the same fact. `VALUE_RANGES` already declares every metric's
plausibility floor for ingest; `value-domain.ts` reads it and answers
"can this metric be negative" for the padded y-domain and for the median
+/- k.MAD band alike. The band's lower edge floors at zero for a
non-negative metric; `spread` stays the unclamped dispersion so a
deviation-in-sigma calculation is unaffected. `buildBaselineBand` now
takes the metric it is describing as a required argument, so a new caller
cannot quietly reintroduce a negative edge. Two types carried no
plausibility range at all, which let any number through ingest and read
as "unknown" on the negativity question: walking steadiness gets the same
0-100 percent band as its two gait siblings, the loud-listening event a
floor of 1 with a generous ceiling.

"13,387.6 steps" also said steps to a reader of German. Five sub-page
units are English words, not symbols - steps, flights, falls, years, and
a "count" that was a poor label in any language. They now travel as i18n
keys through a new `unitKey` on the metric-page scaffold and resolve in
all six locales. The thirteen real symbols (bpm, mmHg, %, m/s, kg/m2,
...) stay literals; translating a symbol would be the opposite mistake.

And the Min read "104.0 steps". Precision was a per-page prop defaulting
to one decimal, so every page had to remember that its metric is a count
- and the step page did not. It follows the metric now: a discrete metric
(tallies, category events, closed integer instrument scores, ordinal
levels) resolves zero decimals wherever it is rendered, and a page only
passes the prop to override that.
2026-07-27 23:01:33 +02:00
Marc-André Bombeck b0862c0902 feat(score): say which yardstick graded the weight row
The weight pillar has been graded against something for two years and
never said what. The score card's weight row now carries one line: the
user's own target band when they set one, or a plain statement that the
row scored the trend with no target at all. The band rides the analytics
DTO in canonical kg and the page formats it into the reader's display
unit, so an imperial account reads pounds.

The weight sub-page shades the same yardstick: its chart bands follow an
explicit target when one exists and fall back to the height-derived band
only when it does not.
2026-07-27 23:00:26 +02:00
Marc-André Bombeck 22d7e8c30c feat(measurements): keep the intraday shape of a cumulative day
A cumulative metric has no meaningful latest reading — a partial day's step
count is a fragment, not a value — so the only honest comparison is against
the same person's own typical total at the same local hour. That needs an
hourly substrate and there was none: the nightly cumulative drain collapses
per-sample Apple Health rows into one daily total after the grace window and
hard-deletes the originals in the same transaction. Today's curve was
derivable; every older day's was already gone.

Fold the rows into twenty-four running totals and write them down first, in
the same transaction and before the delete. The merge is additive so a day
drained twice keeps both halves, and the rows are re-read under the advisory
lock the drain already takes so an overlapping tick folds nothing.

One table, one nightly range delete to bound it.
2026-07-27 23:00:19 +02:00
Marc-André Bombeck 67aa3338da feat(jobs): return an outcome from every named queue handler
Sixty-two handler functions across twenty-four modules now resolve to a
JobOutcome instead of undefined. The counts they already wrote to their
wide event ride out in `did`, so a run that deleted nothing and a run
that could not run are no longer the same shape.

Three rules decided each conversion:

  - a catch that swallowed becomes jobFailed, keeping its warning line;
  - a catch that already rethrew keeps its throw, because the throw
    already fails the job and rewrapping it buries the stack;
  - a fan-out pass is judged on the pass. One account's revoked grant
    belongs on that account's integration ledger, not in a queue-wide
    failure that retries the whole cohort, so per-leg failures ride out
    as a users_failed count — visible for the first time.

The retention cleanups are the behaviour change worth naming: every one
of them used to warn and return, so a purge that had not run for weeks
looked like a purge with nothing to do. They fail now. Their registrar
sets retryLimit accordingly in the commit that converts the bindings.

JobFailure carries the thrown error's stack; without it every one of the
101 queues would report the same runJob frame.
2026-07-27 22:56:59 +02:00
Marc-André Bombeck ffe97d9ad9 fix(backup): carry the account's own cycle symptoms into the payload
The section builder returned them, the wire schema declared them, and the
restore has read them since v1.33.1 — including the throw that replaced the
silent drop. The payload assembly copied its keys out one by one and was never
extended, so every backup written carried an empty list. An account with a
custom symptom did not get the silent drop that release removed; it got a
restore that failed outright on a key nothing had written back.

Spread the cycle and records sections instead of hand-picking their keys, so a
field added to either section interface reaches the wire by construction. Declare
labelEncrypted on the cycle section, which the builder already emitted.
2026-07-27 22:55:05 +02:00
Marc-André Bombeck b1b76ab68a fix(score): grade weight against the user's own target, never a fabricated one
The Health Score graded the weight pillar against 22 x height squared,
expanded by 2 kg either side: a target nobody set, nothing disclosed,
and the only producer of the pillar's band. For anyone with a height on
file it pre-empted both the honest trend-only path the module header
promised and the null-redistribution behind it, while the target the
user had actually entered sat unread in User.thresholdsJson.

computeHealthScore now takes the band whole plus the yardstick it came
from. Both callers resolve it through one shared builder next to
buildHealthScoreBpInputs, which hands over a band only when
getEffectiveRange reports a real user override; every other account
scores the bare trend. defaultWeightTargetFromHeight is gone.

Same pass, same defect class elsewhere: the dashboard bands, the
weight-drift verdict rung and the weight/BMI record direction all
ignored an explicit target too, and the snapshot never resolved the
mood module, so a mood-off account got a mood pillar in the hero ring
and none in the Insights card. moodEnabled and the weight yardstick are
required parameters now, so a caller that forgets one fails to compile
instead of quietly scoring against a default.
2026-07-27 22:54:48 +02:00
Marc-André Bombeck 530f78dcf5 fix(sync): answer with what a run wrote, not that it completed
Six provider syncs returned a bare count, which narrowed their failures away:
a Nightscout window in which every SGV row was refused by a constraint
returned the same 0 as a quiet night, and an Oura or Polar run whose
collections all failed returned 0 too. The routes then answered 200 with a
count and the settings card drew a green tick over it.

Every sync now reports what it imported AND whether any part of it did not
land, and the route resolves the outcome server-side so the web card and the
native client cannot disagree. Google Health keeps its 502 for a run that
failed and wrote nothing, but a run that failed after writing some resources
is now a partial rather than a discarded result. The labs OCR commit carries
the same resolved outcome.
2026-07-27 22:53:43 +02:00
Marc-André Bombeck 39aaef27b1 test(workouts): pin sport provenance across the provider ingests
Behavioural cases first: the Google mapper keeps the raw `exerciseType`
when the sport falls back to "other", reports a missing one as null
rather than an empty string, and the ingest writes it under
`metadata.googleExerciseType` on both the insert and the update arm — the
update is what a full re-sync goes through, so a row written by an older
build repairs in place. The key is omitted rather than nulled when a
session carries no type, so a re-sync cannot wipe what an earlier one
recorded.

Then the structural guard, because the behavioural ones only cover the
provider that was reported. It freezes the set of sport-label mappers and
the set of files creating Workout rows, and asserts every mapping ingest
carries its raw label into metadata. Fitbit has the same gap and is
excluded on purpose — it ships on its own, small and isolated, per the
standing rule for that provider. The exception is an assertion, not an
omission: when Fitbit gains the write, the guard fails and forces the
entry to move. An exception nobody has to retire is how this gap survived
in the first place.
2026-07-27 22:45:54 +02:00
Marc-André Bombeck 52288b7f77 refactor(outcome): lift the write-outcome classifier out of the import panel
The rule that a write which wrote nothing must not read as a success was
scoped to the import panel. Provider syncs need the same rule, so the
classifier moves to src/lib/outcome/written-outcome.ts and the tick plus the
success colour move to one presentation component the whole tree renders
through. Adds the sync shape: a run reports what it imported and whether any
part of it failed, and the outcome is resolved from both.
2026-07-27 22:43:27 +02:00
Marc-André Bombeck 119af56f6f feat(jobs): derive job completion from a returned outcome
A pg-boss handler signalled success by falling off the end of a function.
Every binding in this tree resolved to undefined and nothing read the
value, so a handler that caught an error, logged a warning and returned
was indistinguishable from one that finished its work: pg-boss marked
both completed and the failed-job readers saw nothing.

JobOutcome makes the completion a value the handler has to construct.
runJob turns ok:false into a reported, rethrown failure so the queue's
retry policy applies. createAndWork is the one binding path; its handler
type is (jobs) => Promise<JobOutcome>, so a handler that returns nothing
no longer compiles.

The type cannot make a handler honest — jobDone() after a swallow still
compiles. It converts omission into commission, which is the smaller and
far more reviewable class.
2026-07-27 22:41:02 +02:00
Marc-André Bombeck 3db4c33f49 fix(google-health): keep the raw exercise type on the workout row
An unmapped Google `exerciseType` resolves to the literal "other", and
until now that was the end of the label: nothing else carried it, so a
workout Google typed as a sport the map does not know became
indistinguishable from a genuine `OTHER_WORKOUT`. Carry the trimmed
upstream value through `GoogleHealthMappedWorkout.sportTypeRaw` and write
it to `Workout.metadata.googleExerciseType`, the shape Polar, Strava and
WHOOP already use. Insert and update share one payload, so a full sync
repairs rows written by an older build.

The comment above the type map claimed an unmapped type "still persists"
because the column is free-text. It does not, and it did not when the
comment was written. Prose that asserts what the code does not do is the
same defect as the missing write, so correct it here rather than later.
2026-07-27 22:40:09 +02:00
Marc-André Bombeck 60c30e1e3a docs(changelog): describe the dashboard-layout fix v1.33.1 shipped
The v1.33.1 credit block named a reporter for the dashboard layout while
the body of the notes described nothing of the kind. The fix was in the
release; only its description was missing, which leaves a credit pointing
at nothing and a reader unable to tell what changed on their own instance.
2026-07-27 19:13:34 +02:00
Marc-André Bombeck e21fc24950 Merge pull request #660 from MBombeck/release/v1.33.1
v1.33.1 — what the app knew and did not say
2026-07-27 18:01:38 +02:00
Marc-André Bombeck 4d1d712b93 chore(release): v1.33.1 — what the app knew and did not say
Eight workstreams, two migrations, and a pass by an independent reader that
found four things this release was claiming about itself that were not true.

Two of the fixes change what people receive. Medication reminders went out
every fifteen minutes because the dedup key could not tell one dose slot from
another, and "handled by the app" was silencing every channel rather than the
one it names. Both are called out in the notes, because a user who set that
flag as a general mute will notice.

Two came from reports: an OpenAI-compatible gateway that the provider list
had no room for, after an earlier answer from us said otherwise, and four
places where a timestamp followed the browser instead of the profile.

The backup half is the one worth reading. Three record types were carried and
could not be restored, or could not be carried at all, and each failed in a
different direction: one dropped silently, one stopped the restore outright,
one was discarded while the restore reported success. What a backup holds is
now declared in one file with a written reason for everything left out, and
the roughly thirty types that are still carried by neither end are named there
instead of waiting to be discovered by someone who needed them.
2026-07-27 17:41:50 +02:00
Marc-André Bombeck 99836bb08c fix(documents): make the delete-key hint readable enough to be a hint
The Del chip added beside the document card measured 4.06:1 against the card's
background, under the 4.5:1 floor, and axe failed the vault on it. So the
change that made the destructive key visible introduced a barrier of its own.

It uses the full foreground token now. The design rule says muted for meta
text, and a key hint is meta — but it is the one kind of meta that has to be
readable at a glance, so here the contrast rule wins and the reason sits next
to the class list rather than in a commit nobody will read again.

Found by running the e2e suite locally before pushing rather than after.
2026-07-27 17:41:49 +02:00
Marc-André Bombeck ea55886445 fix(tz): close the hole that let a display option pass for a timezone
The guard tested whether the argument list contained the substring "timeZone".
`timeZoneName: "short"` contains it. That option appends "GMT+1" to the output
and leaves the zone as the browser's, so a formatter written that way sailed
through while being exactly as wrong as a bare one — and looking more careful
than a bare one, which is worse.

Tightening it to a colon-terminated property then reported nine correct call
sites, because the shorthand `{ timeZone }` is what most of this tree writes.
The honest response to that would have been to allowlist all nine, which is
how a guard stops guarding. It accepts both forms now, and a test pins both
directions rather than leaving the regex to be trusted.

Two exclusion reasons in the backup plan aged inside their own release and are
corrected: push attempts now also hold the reminder dedup anchors this release
introduced, and the thumbnail's "rebuilds from the stored bytes" only holds on
the disaster-recovery path, since a portable export carries no bytes to rebuild
from.
2026-07-27 16:10:30 +02:00
Marc-André Bombeck 4ad498dbff fix(backup): carry a custom tag's own words and its scale, and stop claiming a guard that does not exist
Four things an independent read found, three of them things this release said
about itself that were not true.

A custom mood tag travelled without its label, its icon, its sort order and
its SCALE. The label is the smaller loss: the person who named a factor
"Migräne" would have got a key back. The scale is the real one — the inverse
flag marks a factor where a high value is bad, and dropping it does not lose a
label, it silently reverses the meaning of every rating already recorded
against that tag. Custom cycle symptoms lost their label the same way. Both
now carry the ciphertext verbatim, like every other encrypted column in a
disaster-recovery payload.

The plan file claimed, in the present tense, that a guard requires both a
payload reader and a restore branch for every carried model. No such guard
exists. The commit that introduced the file said so honestly; the file itself
said the opposite, and roughly 31 models have neither end. BACKED_UP means
"must be", not "is", and both the plan and its test now say that plainly. The
wrong sentence is recorded rather than quietly swapped, because it is the same
defect the file was written to prevent.

The export route still told operators that labs, allergies, illness episodes
and workouts could be exported but never restored. The restore route
re-creates all of them, and since this release nutrient day totals too. The
scope now points at the plan instead of repeating it beside the code, which is
how that sentence came to be wrong.

And the export card promised "every record you own". It does not, so it no
longer says so — six locales.
2026-07-27 15:20:12 +02:00
Marc-André Bombeck 2449e381b9 docs(self-hosting): say that the offline tier alone does not stop the lookup
The resolver is online-first, so mounting the databases fills the ASN
and carrier but does not by itself keep login IPs on the host. Point at
the IP_GEO_LOOKUP_DISABLED section that does.
2026-07-27 14:47:57 +02:00
Marc-André Bombeck 5f3cad759a fix(dashboard): let the native-only group describe this page, not the app
The hint said switching a widget off here turns it off in the mobile
app. This page writes the flag; whether the client applies it on its
next read is the client's business, and there is an open report that it
does not. Say where the setting lives instead of promising a result
this surface does not control. Six locales.
2026-07-27 14:47:57 +02:00
Marc-André Bombeck 7997e2c61f feat(dashboard): make the native-only widgets configurable from the web
BMI renders on the native dashboard and was configurable from nowhere.
Two individually-correct decisions produced it: `bmi` is a catalogue id
with no web render path, and the web layout editor filters those out
because a toggle over something this page cannot draw would be a silent
no-op. The widgets PUT accepts and persists them all the same, and the
native client writes them, so an account ended up holding rows it never
set and could not change from any surface.

The sortable list stays exactly as it was. Below it, the ids the web
does not draw but the layout carries get their own labelled group with
the same two flags and no reordering — placement belongs to the client
that draws them. An account with no native client has nothing there and
sees nothing. The module gate applies as it does above, fail-open.

Refs #581
2026-07-27 14:47:56 +02:00
Marc-André Bombeck e4a4c996b7 fix(documents): stop Backspace deleting a document, and show the key that does
A focused card deleted on Backspace as well as Delete. The timeline's
roving tabindex gives a card focus the moment someone tabs into the
grid, Backspace is the reflex "go back" key, and a card carries no
delete control at all — so the reflex press removed a medical document
with nothing but a transient undo toast behind it.

Delete alone now deletes. The key resolution moves into a pure helper
in vault-utils so the contract is testable without a render harness,
and the card stops keeping that contract in a code comment: the overlay
button carries aria-keyshortcuts, and a kbd chip appears while the card
holds focus. The label is per-locale (Entf / Suppr / Supr / Canc).
2026-07-27 14:47:56 +02:00
Marc-André Bombeck 20824aa798 fix(documents): say that turning auto-read on reaches back over the vault
The switch was described three times as applying to what you upload
next. A genuine off-to-on flip also enqueues a pass over the documents
already stored without a summary, capped at 200 per flip — scanned
medical documents going to a third-party provider, from copy that reads
as forward-looking only.

Drop "newly uploaded" from the sub-label and give the disclosure the
sentence it was missing, including the cap. Six locales. The behaviour
is right and stays as it is; only the sentence changes.

A guard pins the number in the copy to MAX_ENQUEUES_PER_RUN, so moving
the cap without touching the copy fails.
2026-07-27 14:47:55 +02:00
Marc-André Bombeck 189024cc00 fix(geo): make GEOLITE2_DIR reachable and tell self-hosters what they can do
The runtime has always read GEOLITE2_DIR, but the variable was absent
from the compose `environment:` whitelist, from both env examples, and
from the docs. A variable that is not on that whitelist never reaches
the container even when the operator's `.env` sets it, so the override
existed in code and was unreachable from the documented path.

Put it on the whitelist, document it in both examples and the manifest,
and add `docs/self-hosting/geolite2.md`: fetch the databases with a free
MaxMind account, mount the directory read-only, point the variable at
it. The compose file carries the bind-mount line commented out beside
the app service.

The admin notification told the reader to set MAXMIND_LICENSE_KEY in
this repository's Actions secrets — something nobody running the
published image can do, about a fallback that sends their sign-in
addresses to a third party. It now names the mount, names the online
provider it is actually querying, and links the runbook. Six locales.
It also no longer claims the carrier column stays empty; the online
tier has filled the carrier from the ISP field since v1.25.8.

A blank GEOLITE2_DIR now resolves to the default instead of the process
working directory: compose substitutes an unset variable to the empty
string, which is not nullish.

Refs #659
2026-07-27 14:47:55 +02:00
Marc-André Bombeck 832a269ab3 refactor(medications): split the supply tab at the dialog boundary
The Bestand tab was 1,139 lines of one client file holding two concerns that
only meet at the row shape: the list of containers a person holds, and the
three dialogs that change it. Registration, per-item correction, packaging
economics, the summary and the row rendering all shared a scope, so reading
any one of them meant scrolling past the rest.

Move-only. The dialogs go to `inventory-dialogs.tsx` with the day-input
conversions their fields produce; the row shape and the cache-invalidation
they both need go to `inventory-shared.ts`; the section keeps the summary and
the list at 458 lines. Every export the tab offered is still offered here, so
no caller changes. No copy changes, no new keys.

Two suites pin these contracts by reading the file's own source — the register
dialog's unit conversion and the correction dialog's sparse patch. Their
`readFileSync` now names the file the code moved to. The assertions are
unchanged, byte for byte; only the path they read is.
2026-07-27 14:47:55 +02:00
Marc-André Bombeck 1c05bba56c docs(jobs): correct two migration citations and the import dedup contract
The retention-purge comments still cite migration 0277 for indexes that landed
in 0276 — the renumbering moved the directories and left the prose behind. The
schema is trustworthy because its comments are, so a wrong number is not
cosmetic.

The import worker's `already_recorded` reason claimed the skip covers a dose
recorded on any other intake surface. It does not: `source` is part of the
live-row unique, so a hand-logged dose and an imported one at the same slot are
different keys and both are written. Say what the mechanism actually does, and
say plainly that cross-surface dedup is not there.
2026-07-27 14:47:55 +02:00
Marc-André Bombeck 1a6e523b29 refactor(medications): one admission path and one replay key for intake imports
The per-medication import and the account-wide dose-history import queued
their work through ninety near-identical lines each: row lock, stale-job
sweep, active-job refusal, job row, pg-boss send, and the fallback that fails
the row when the queue is not there. Five steps written twice drift, and the
drift shows up as an import that hangs on one surface and not the other. The
status projection moved out of these routes for the same reason; this is the
write half.

Scope is the only real difference, and it is one argument. What the two
surfaces legitimately say differently — the 409 wording, the envelope, the
kickoff audit line — stays at the call site.

The replay key had the same shape of problem in smaller form: the export
reader owned a function for it and the hand-listed body inlined the same
template. Both now call one function, with a test that pins them equal for
the same medication and instant.
2026-07-27 14:47:55 +02:00
Marc-André Bombeck 146a02319d refactor(medications): give the in-use clock list one owner
The supply tab carried its own copy of the two container kinds whose first
opening starts a degradation clock, with a comment saying it mirrored the
state machine's list. Nothing enforced the mirror — the state machine types
itself against the generated Prisma client, which the browser bundle cannot
carry, so a copy was the only way to say it.

Move the list into a client-safe module with no imports and let both sides
read it. The state machine re-exports it under the Prisma enum, so a renamed
or dropped container kind is now a compile error rather than a member that
quietly stops matching.
2026-07-27 14:47:54 +02:00
Marc-André Bombeck 6ab3bc87ff test(analytics): pin the effective-range resolver's sex-split defaults
The resolver reads the profile's sex for the body-fat and pulse bands, and
nothing asserted it. A resolver that stopped reading the field, or read it and
defaulted to one side, passed the whole suite. Pin both recorded sides and the
neutral band that OTHER and an unanswered profile share.
2026-07-27 14:47:54 +02:00
Marc-André Bombeck 36f525c465 refactor(profile): route every binary-sex narrow through the one owner
`binaryReferenceSex` was already the declared owner of the shrink from the
three stored values to the two a sex-split reference table has rows for, but
eight sites still narrowed inline — including a byte-for-byte private copy in
the effective-range resolver and a ternary 37 lines above a `toProfileSex`
call in the same file. Each of those is a place the fallback can drift.

Delete the copy, replace the ternaries, and name the pair once as
`BinaryReferenceSex` so a signature that needs it stops re-listing the two
literals. No behaviour changes: every site already resolved OTHER and "no
answer" to the same neutral or absent value, and the existing suites pass
untouched.
2026-07-27 14:47:54 +02:00
Marc-André Bombeck eba9f15539 test(integrations): prove the leg backfill against rows that carry the old value
A migration container starts empty, so applying 0281 there proves the
statements parse and nothing else — the backfill's WHERE matches no rows and a
broken expression passes green. Every self-hoster runs it against rows that do
carry a leg, and those are the only rows it exists for.

Seed the pre-migration shape, run the migration's own UPDATE read out of the
committed file, and assert an attributed row comes out still knowing which leg
broke, an unattributed one is left alone, and a second run does not nest the
array.
2026-07-27 14:35:34 +02:00
Marc-André Bombeck d222aa2354 test(measurements): pin the null-trigger stamp on a batch that declares none 2026-07-27 14:35:34 +02:00
Marc-André Bombeck 7e4b9d9be9 test(integrations): cover the leg-set semantics a single slot could not hold 2026-07-27 14:35:33 +02:00
Marc-André Bombeck 4e95df9e8d feat(integrations): tell the Apple Health card how the last sync arrived
The batch route has accepted a `syncTrigger` since v1.32.8 and put it on the
wide event, where no human reads it. Half of what was promised got built: the
value is written and appears in three places in the codebase, none of which is
a screen anyone looks at.

Persist it on the account, alongside the instant of the most recent batch that
arrived without the app being opened, and publish both from
`GET /api/integrations/healthkit`. The card now names how the last sync arrived
and when data last came in on its own. `healthKitLastSyncedAt` alone reads
identically whether the phone delivers by itself or only while someone has the
app open, which is why "is background delivery working" could not be settled
from either side.

A batch that declares no trigger writes null rather than leaving the previous
one standing — the field describes this sync, and an older client's silence is
not evidence about it. A foreground batch leaves the background timestamp
alone; overwriting it would erase the only answer the card has.

Also here: WHOOP, Fitbit and Google Health join the workout-freshness table.
All three write workouts, and without an entry their cards could report every
measurement type fresh while the workout leg had been silent for weeks. And the
status pill drops a generic `aria-label` that replaced its own accessible name,
so a screen reader announced the same words for a healthy connection as for a
broken one.
2026-07-27 14:35:33 +02:00
Marc-André Bombeck 06935256b0 feat(integrations): let the ledger hold every failing leg at once
`failing_leg` was one slot, so two legs of the same provider failing in
overlapping windows overwrote each other. The row remembered whichever failed
last, and that leg recovering then cleared an error the other one was still
causing — the ledger forgot a live failure and painted the card green over it.

The slot becomes a set. A failure adds its leg, a success removes its own, and
the error state, the per-kind buckets and the streak anchor clear only once the
set is empty. The array is ordered oldest failure first, so the last element is
the leg that wrote the message `last_error` holds; when that leg recovers while
others are still failing, the message is cleared rather than left describing a
leg that is now healthy.

An unattributed failure adds nothing and removes nothing. A single-leg provider
therefore behaves exactly as before, and a multi-leg provider no longer loses
what its other legs already reported.

The column never reached a client — it is absent from the status serialiser and
from the OpenAPI contract — so it is replaced rather than shimmed.
2026-07-27 14:35:33 +02:00
Marc-André Bombeck 3088753a92 docs(reminders): drop the stale client-managed cross-reference
The module-gate branch cited the client-managed skip as its precedent for
advancing past a cycle. That skip no longer exists in this file, and the
reason the gate advances is its own: a disabled module has nothing left
to deliver.
2026-07-27 14:26:20 +02:00
Marc-André Bombeck ff076e80b1 fix(notifications): collapse APNs pushes per dose slot, not per event type
`apns-collapse-id` is a replace directive: a push carrying an id
supersedes the earlier undelivered push with the same id. Every
medication reminder carried the literal string MEDICATION_REMINDER, so
three medications due at 08:00 arrived on the phone as one notification
naming one of them. The user takes that one and misses the others with
nothing on screen to suggest anything was dropped.

Key the collapse on the per-slot tag the Web Push sender already
coalesces on, falling back to the event type when a payload carries none.
Grouping stays on `threadId`, which is what grouping is for. Discreet
mode keeps the generic id — no record-identifying string on the lock
screen — and a tag over Apple's 64-byte ceiling falls back rather than
costing the send.
2026-07-27 14:26:20 +02:00
Marc-André Bombeck c6b944b7fb fix(reminders): send a dose reminder once per slot, phase and day
The per-phase "already sent today" guard read the Telegram message
ledger, and that table is written only when a Telegram send succeeds. A
user on email, APNs or ntfy never had a row, so the 15-minute tick
re-derived the same overdue slot and sent it again on every pass — around
fifty mails for a single missed dose.

Anchor the guard in `push_attempts` instead, the ledger the daily
briefing and the illness escalation already use. Two properties are
deliberate: the anchor is stamped for every dispatched slot regardless of
what any channel returned, because keying it on a delivered push would
re-open the flood the moment a channel starts failing; and it is stamped
before the dispatch, so a crash costs one phase's notice rather than a
repeat every quarter hour.

The key carries the schedule id and the slot's real HH:mm alongside the
phase and the local date. Both are load-bearing: two legacy
single-window schedules on one medication share the empty first-class
time-of-day, so a coarser key would collapse the evening dose into the
morning one. The tests assert the other direction too — the next phase
still escalates, and the next local day starts clean.

The Telegram ledger keeps its message-tracking duties; it is no longer
the dedup key. Anchor rows carry a sentinel channel and stay out of the
operator's notification health and diagnostic reads, which count
deliveries, not decisions.
2026-07-27 14:26:20 +02:00
Marc-André Bombeck fcc4e661df fix(notifications): scope client-managed suppression to the APNs leg
`medication.clientManaged` and `measurementReminder.clientManaged` mean
the iOS client schedules the reminder locally, so the server should not
push it. That is what the OpenAPI description, the settings copy and the
iOS contract all say. What the crons did was skip the dispatch entirely,
so a user who flipped the switch also lost Telegram, ntfy, webhook,
email and Web Push — channels they may well read on a desktop while the
phone holding the local schedule sits in a drawer.

Move the gate into the dispatcher's channel loop, where it applies to
APNs and nothing else. The skip is not counted as an attempt, so
`dispatched` keeps meaning "something reached the user", and it stays
countable: the same wide-event keys the crons emitted are emitted here,
plus a `skipped` row in the push-attempt ledger.

The Vorsorge path is the sharper half. It suppressed every channel and
then advanced `nextDueAt`, which dropped the reminder out of the
preventive-care card and out of the daily digest: a reminder that never
reached anyone was recorded as handled. The advance now happens only
where it always belonged, after a dispatch that a channel actually
delivered. When nothing delivers, the reminder stays overdue and visible.
2026-07-27 14:26:20 +02:00
Marc-André Bombeck cd30cb3a52 fix(backup): carry the mood tags an account made, so its own file can be restored
The restore resolves a mood entry's rated factors by key and asks only for the
seeded catalogue. A tag the user created is not in it, so the lookup came up
short and the whole restore threw 'Unknown mood factor keys'. The file was
complete; it simply could not be read back.

That is the same cause as the cycle symptoms two commits ago, with the
opposite ending. There, an unresolvable key was filtered out and the loss was
silent. Here it stopped everything. One shape of missing definition, two
answers, neither of them the right one.

The account's own tag definitions travel now, get written before the factors
resolve, and the lookup accepts the seeded catalogue or the owner's own. The
seeded rows stay out of the file for the same reason as before: a restore must
not rewrite another instance's reference data.

Four more test mocks needed the new delegate, which is again the compiler and
the suite naming the call sites rather than me hunting for them.
2026-07-27 12:41:45 +02:00
Marc-André Bombeck d9afb23067 fix(backup): restore the nutrient day totals the export has always carried
The export has written nutrient day totals since v1.29. The canonical schema
never declared the field, so `.passthrough()` carried it through parsing and
no reader ever looked at it: every water and vitamin total in the file was
discarded on restore, and the restore reported success.

Declared in the contract, cleared and re-created beside its siblings, and
counted in both places that count — what the file holds and what the restore
replaced. A count that stops at the domains someone remembered is how this
stayed quiet.
2026-07-27 12:02:01 +02:00
Marc-André Bombeck b2e19ce0b6 fix(backup): carry the symptoms an account made, and stop dropping the ones it cannot find
restoreCycleData was loud about one missing reference and silent about
another, three lines apart. An unknown cycle reference threw; an unknown
symptom key was filtered out of the list, so the day-log came back with one of
its symptoms quietly gone and the restore reported success.

The cause was upstream: the seeded catalogue is reference data every instance
has, but a symptom the user created lives only in their account and was never
carried. The key had nothing to resolve against, and the filter turned that
into silence.

Both ends now. The account's own definitions travel with the backup, the
seeded rows deliberately do not — shipping those would let one instance's
restore rewrite another's reference data. And the resolve step throws on an
unresolvable key rather than filtering it, so the same function answers the
same question the same way twice.

Widening the builder's client type made four export tests fail on a missing
delegate, which is the sweep working: those are the call sites, named by the
compiler instead of found by reading.
2026-07-27 11:22:22 +02:00
Marc-André Bombeck a2e9587781 feat(backup): declare what a backup carries, and force a verdict on the rest
The mirror of the wipe plan, for the same reason and against a worse failure:
a list that was right when written and stops being right every time a model is
added. On the delete side that leaves data behind; here it means data does not
come back, and nothing says so until someone is restoring a backup because
something already went wrong.

The list is longer than the wipe list by sixteen models, and that gap is the
point. A wipe may lean on the database — deleting a medication cascades to its
schedules, dose changes and inventory events — but a restore has no cascade,
so every child has to be named here.

Every exclusion carries its reason in prose, and the guard enforces a length
floor on it. That floor is not pedantry: the first draft of this file said
'Same as WithingsConnection' four times, which is a pointer rather than a
reason, and a pointer silently inherits whatever the thing it points at
becomes. Writing the decision where it is made is the only way it stays
reviewable.

The guard found two things in its own author's work on the first run: a model
with no verdict at all, and a reason too thin to be one.

The restore side of the guard — that every carried model also has a restore
branch — lands with the payload work it will police.
2026-07-27 05:14:39 +02:00
Marc-André Bombeck 8e9da490a9 test(tz): refuse a date formatter that does not name its calendar
A formatter with no timeZone follows the browser, which is a different
question from the one this app answers. Four surfaces had drifted apart that
way before anyone noticed, and one of them printed a shared report a day off
from the page it came from.

The admin host chart went with them rather than onto the allowlist. Server
samples are real instants, and an operator reading their own instance should
not find a spike at 14:20 here and 15:20 two screens away.

Two deliberate limits, both written into the test. It does not match
toLocaleString, which in this tree is overwhelmingly a number formatter, and
burying four real hits under every thousands separator is how a guard gets
silenced. And it cannot tell a Date built from calendar arithmetic — a month
label, a weekday header, no instant behind it — from one parsed out of stored
data, so those four sites carry a written reason instead. The value is the
ratchet: a new bare formatter has to be argued for before it ships.
2026-07-27 00:18:22 +02:00
Marc-André Bombeck f1773cf602 fix(charts): draw bucket boundaries in the profile's calendar, not Berlin's
The bucketer hard-coded Europe/Berlin for day, week and month boundaries, so
a reading taken on the last evening of a month landed in the wrong month's
bar for everyone who does not live there. Two smaller siblings did the same
thing: the Today chip asked the browser for a time instead of the profile,
and the score card's tooltip formatter carried a locale but no timezone.

The boundary timezone is a required option rather than a defaulted one. A
default would have left any caller that forgets to pass one still wrong and
still silent; required makes the compiler name them, which is how the three
call sites and the eight test sites in this diff were found rather than
guessed at. The tests were always asserting Berlin's calendar without saying
so, and now they say so.

Two fixture mistakes worth recording, both caught by running the tests rather
than trusting them: summer time is already in force in Berlin on 31 March, so
the first instant chosen was April on both sides; and 31 March 2025 falls in
the same ISO week as 1 April, so the week case needed its own instant where
one side is still Sunday.
2026-07-26 23:18:12 +02:00
Marc-André Bombeck 87877b285f fix(share): date a shared PDF by the owner's day, not the display default
The page at c/[token] resolved the owner's timezone; the PDF download beside
it resolved nothing and fell through to the display default. Same link, same
reading, two dates — and the one a practice files is the one that was wrong.

Resolved in the shared download gate rather than in the route, so the PDF and
the FHIR bundle cannot drift apart the way the page and the PDF did. The
owner's clock preference travels with it for the same reason.

The fixture is deliberately west of UTC: Berlin is the fallback, so a Berlin
fixture cannot tell a resolved timezone from a forgotten one.
2026-07-26 22:37:10 +02:00
Marc-André Bombeck 6d800f277d fix(rotation): keep the gateway bearer's coverage guard real
The rotation-coverage guard greps the script for a quoted column name.
Listing the new column in the header comment satisfied that grep without
the script rotating anything, which made the guard pass on a script that
would have left the column behind. The comment now names it unquoted and
says why.
2026-07-26 22:27:48 +02:00
Marc-André Bombeck b74612eaae docs(ai): document the OpenAI-compatible gateway provider
Names LiteLLM, OpenRouter and vLLM, states what it does not share with
the OpenAI provider and why, and keeps the Local provider's own section
intact — it still speaks the same wire and remains the right pick for a
model on your own machine.
2026-07-26 22:27:48 +02:00
Marc-André Bombeck 3ef6a7119b test(ai): pin the gateway provider's isolation and host policy
The properties this provider has to keep: it resolves against the base
URL that was saved for it, the pinned OpenAI arm cannot see that URL and
still posts to api.openai.com, and the OpenAI key never travels to the
gateway. Each is asserted at the wire, not on internals.

The SSRF floor is checked through the same alt-notation classes the
Local provider is checked against, and the pinned tags are shown to keep
requiring a public host even when the operator opened every private one.

The AI settings connect-src is pinned to what it already allowed: the
gateway is called server-side, so no CSP change is needed, and a future
change that needs one will turn this red.
2026-07-26 22:27:48 +02:00
Marc-André Bombeck ee620b2707 feat(settings): configure the OpenAI-compatible gateway from Settings
Its own form with its own three fields, its own entry in the provider
dropdown and in the fallback-chain editor, and copy in six locales that
says plainly where the OpenAI key does not go.

The chain editor offers the entry but the default chain does not carry
it: it resolves only against a base URL somebody saved.

The read endpoint reports the gateway's base URL and model, and the
bearer as presence only. OpenAPI regenerated.
2026-07-26 22:27:48 +02:00
Marc-André Bombeck 0c7059804b feat(ai): resolve an OpenAI-compatible gateway as its own provider
The chain gains a seventh entry. It builds the OpenAI client against the
base URL the user saved, with the bearer they saved for it and the model
they named, and it reads none of the OpenAI columns — the pinned
api.openai.com arm stays pinned.

Host policy is the Local provider's, unchanged: a public host always, a
private one only where the operator allowlisted it, which is what a LAN
LiteLLM or vLLM needs. Egress keeps riding safeFetch.

The JSON-mode dialect the Local client learns per endpoint moves into a
shared module and now serves both clients, so a gateway that rejects
response_format is detected once and retried without it.

The entry stays out of the default chain: it can only resolve against a
base URL somebody configured.
2026-07-26 22:27:48 +02:00
Marc-André Bombeck d11019aebc feat(ai): add the storage for an OpenAI-compatible gateway provider
Three dedicated User columns — base URL, optional bearer, model — for a
provider whose endpoint the user chooses. They are separate from the
OpenAI columns on purpose: the OpenAI arm pins api.openai.com, so it must
share no column with a user-supplied host, and a gateway must never
receive the key issued for OpenAI.

The bearer joins the encrypted-column registry and the rotation script;
the three columns join the account-wipe reset set.
2026-07-26 22:27:48 +02:00
Marc-André Bombeck d10ae9e71c test(e2e): gate every measurement on its element, and keep it that way
Four specs navigated and then read the DOM once, with nothing in between
that waits for anything but the network. An idle network says nothing about
whether React has rendered, so the read can sample a tree that is still
mounting and the assertion then blames the application for a race inside the
test. Two such reads went red on consecutive CI runs, on different specs.

The settings over-scroll guard also slept a fixed 500 ms, which is the wrong
shape twice over: too long when the page is ready, too short on a loaded
runner, and it never says what it is waiting for.

The structural guard runs in the unit suite rather than in Playwright,
because e2e runs at PR-CI only and flaky counts as failed there — one
unguarded read otherwise costs a whole pipeline before anyone sees it. It
refuses to count a wait whose rejection is thrown away, since that reads
like a guarantee and is not one, and would leave the check defeatable by
appending a catch. The one deliberate swallow is on the allowlist with its
reason: a genuine regression there still fails at the width assertion below
it, which reports the measured number instead of a timeout message.
2026-07-26 22:04:58 +02:00
Marc-André Bombeck 26f8325c26 docs(releases): carry the v1.33.0 release image in the repository
A release asset is served with content-disposition attachment and nosniff, so
an image embedded from that URL renders as a broken image in the notes. The
raw path serves it as an image, which is the only thing that actually works.
2026-07-26 20:52:41 +02:00
Marc-André Bombeck 7e5ea64799 Merge pull request #658 from MBombeck/release/v1.33.0
v1.33.0 — what was silently failing
2026-07-26 20:35:25 +02:00
Marc-André Bombeck f5049fc59f test(e2e): gate the settings checks on the elements they measure, not on the network 2026-07-26 20:19:55 +02:00
Marc-André Bombeck 9c79ea41fa test(e2e): settle the page before the overflow self-test injects its canary 2026-07-26 20:03:55 +02:00
Marc-André Bombeck 81a8e49333 ci(integration): give the hang guard enough room to tell a hang from a slow runner 2026-07-26 19:43:17 +02:00
Marc-André Bombeck 7967224f48 docs(changelog): name the two additions this release gained and the people who asked for them 2026-07-26 19:24:46 +02:00
Marc-André Bombeck 3f355fbde4 feat(charts): put the drawn data points under the chart as a table
A chart shows the shape and hides the numbers. An appointment needs the
numbers, and so does anyone moving readings into a spreadsheet.

The table reads the same expression the chart hands Recharts, after the range
slice, the week/month bucketing and the display scale, and it formats through
the chart's own value and date formatters. Nothing is re-derived, so a cell and
the tooltip cannot print two numbers for one point.

Collapsed by default, with the point count in the trigger so the size reads
while it is closed; the region scrolls in its own box rather than growing the
page, and the table primitive already scrolls a wide row set sideways. The
caption names the grain, because a weekly mean labelled as a daily reading
would be a lie, and the grain now has one source shared with the bucketing.
Derived overlays get no column: a moving average is drawn from the points, not
measured. An empty window renders no table at all, since the chart's own empty
state already says there is nothing there.

Opt-in per mount. The metric sub-pages take it; the dashboard tiles do not,
because a table under every tile buries the dashboard it summarises.

Refs #636
2026-07-26 19:23:52 +02:00
Marc-André Bombeck 8dc5c230da feat(insights): pair a mood tag against next-day resting heart rate and HRV
The tag crosstab already ran a with-versus-without Welch test per structured
tag, with a D to D+1 pairing mode whose stated example was an evening tag
against next-day recovery. It only ever had one channel at that lag, and a
recovery score is a wearable composite plenty of accounts never record. The
rated-factor board next to it has tested next-day resting heart rate and HRV
since v1.14.0, but a rated factor is a continuous score, so a binary tag never
reached either vital.

Add both to the tag map. Same engine, same per-side day floors, same
Benjamini-Hochberg family, same card. The fetch filter derives from the map, so
the channels come along without a second query.

The delta read-out goes neutral to match the rated-factor board: with resting
heart rate on the board, a positive delta is worse on one row and better on the
next, and a green plus sign would claim a verdict the association cannot carry.

Refs #616
2026-07-26 19:23:52 +02:00
Marc-André Bombeck 4be01a67f0 chore(release): fold in the coach discriminant, the dependency bumps, and the notes the audit found wrong 2026-07-26 18:57:21 +02:00
Marc-André Bombeck 5a71f376d4 refactor(coach): keep the manifest's out-of-window rows terse
The preamble already says once what the two states mean and that neither is
absence; repeating it per row would have doubled a manifest that rides every
turn.
2026-07-26 18:34:42 +02:00
Marc-André Bombeck 2a9b5ba59d test(coach): prove the availability probe's SQL against Postgres
The unit suite mocks Prisma, so it proves the discriminant and nothing about the
query underneath it — a wrong column would have left every test green and every
account answering no_data_unconfirmed in production. Seeds the reported case and
the four other shapes: an empty record, another account's rows, tombstoned rows,
and a history that mixes units.

Rounds the out-of-window mean to one decimal, like every other mean the snapshot
surfaces, and annotates a failed manifest probe rather than letting it degrade in
silence.
2026-07-26 18:34:42 +02:00
Marc-André Bombeck 626365f138 test(coach): assert the removed instruction is gone, not only that the new one is there
The mutation check found both holes: an addendum test that only looked for the
new reason codes passed with the old declare-absence-and-move-on sentence still
in place, and a block-failure check whose window was wide enough to see the
neighbouring block's annotation.
2026-07-26 18:34:42 +02:00
Marc-André Bombeck 59a435d5ac test(coach): pin the window/absence discriminant to the reported case
The fixture is the report: a fortnight of glucose readings from April 2024 on an
account that displays them everywhere else. The clock is pinned so it cannot age
out of the window it was written to sit outside of.

Covers the four states an empty window read can resolve to, that a never-recorded
metric and a historical one differ in the SAME run, that a mean is withheld
across mixed units rather than fabricated, that the manifest advertises the range
instead of "absent", that both instructions branch on the reason code, and that a
miss carrying figures reaches the prose verifier's authoritative set. Adds the
medication-compliance page to the failed-read sweep and pins the narration
blocks' failure annotation.
2026-07-26 18:34:42 +02:00
Marc-André Bombeck a402eaaf15 fix(coach): tell a history outside the window apart from a metric never recorded
A Coach retrieval tool that read the window and found nothing returned
`reason: "no_data"` — the same code for two situations that have nothing in
common: the record holds nothing for that metric, and the record holds a
substantial history for it, all of it older than the window that was searched.
Nothing downstream could tell them apart, so the Coach said the honest-sounding
sentence for the wrong case, and then, having been told there was nothing there,
changed the subject. Someone with a year of imported glucose readings was told
the readings did not exist, on the same account whose glucose page was showing
them.

The window cap stays. It exists because a multi-year history does not fit a
prompt budget, and widening it would only move the same failure. What was
missing is the ability to say a different true sentence.

- `tools/availability.ts` — one grouped aggregate per miss, no rows
  materialised, resolving four states: `no_data` (probe confirmed empty),
  `outside_window` (rows exist, older than the searched window; carries the
  count, the date range in the user's own timezone, and a bounded per-series
  mean/min/max), `unavailable_in_scope` (rows exist inside the window and the
  domain still produced none — a module is off), and `no_data_unconfirmed` (the
  probe itself failed; absence was never established, so it is not asserted).
- Every empty read in the executor routes through it. A bare `no_data` can no
  longer be returned without the record having been checked.
- The DATA INVENTORY carries the same distinction, so a domain the window
  cannot reach is advertised with what it holds instead of as absent — the model
  no longer skips the fetch, and a vague follow-up keeps the subject.
- Both instructions that told the model to report absence and move on now
  branch on the reason code, and forbid answering about one metric by reporting
  another.
- The out-of-window aggregate joins the authoritative set for the prose
  verifier, so the sentence the fix exists to make sayable is not elided.
- A cross-cutting narration block that throws is now counted rather than
  vanishing into a `null` that looks like honest absence.
- The medication-compliance page tells a failed read apart from an account with
  no medications, instead of inviting someone to re-enter what it already holds.

Refs #648
2026-07-26 18:34:42 +02:00
Marc-André Bombeck 24d699f252 chore(bundle): re-measure the client budget for the surfaces this release adds 2026-07-26 18:26:45 +02:00
Marc-André Bombeck dbac3d3abd chore(release): v1.33.0 — what was silently failing 2026-07-26 18:18:28 +02:00
Marc-André Bombeck 9db7df1d4d test(db): follow the inventory repair to its new number 2026-07-26 18:02:35 +02:00
Marc-André Bombeck 500025f1e5 chore(db): number the inventory repair last in the chain 2026-07-26 18:00:20 +02:00
Marc-André Bombeck 5b38838594 test(medications): type the container fixture so the overrides compile
`Partial<typeof item>` inferred the literal types off the base fixture, so an
override could not swap the container kind or null a date. The fixture takes
an explicit row shape instead.
2026-07-26 18:00:08 +02:00
Marc-André Bombeck d91b7cb57a test(medications): cover the container kind on the import and refund paths
Hardcoding the container kind in the batch-import consumption path or in the
refund path left the whole suite green — neither had a scenario that read a
clock-less container. Both now do: a back-dated import chunk against a tablet
pack stays IN_USE with no deadline, and a refund that brings units back to a
long-opened pack reads IN_USE rather than expired. The pen counterpart of the
import case is pinned alongside.

The fake client's `updateMany` learns the `id: { in: [...] }` form the batch
claim uses; it only understood a single id before.

The no-runway arm of the low-stock evaluation now has its written-off count
asserted too.
2026-07-26 18:00:08 +02:00
Marc-André Bombeck 7b59388887 test(medications): make the repair's idempotency assertion able to fail
The comparison now includes `updated_at`, so a second application that
rewrites an already-correct row is visible even when the value it writes is
identical. Without it the assertion passed whether or not the guard that
skips untouched rows was there.
2026-07-26 18:00:08 +02:00
Marc-André Bombeck f226a907cb fix(medications): clear the pending clock deadlines the repair would have missed
The first pass of migration 0276 restored the rows the in-use clock had
already flipped, but every clock-less container still IN_USE carried the same
clock-derived `expires_at`, most of them with the deadline still ahead. The
daily expire scan would have worked through them over the following weeks and
reproduced the whole defect one row at a time.

A clock-less container's `expires_at` is now set from its printed expiry —
usually nothing, which is how it stays out of that scan. Pens and ampoules
keep theirs; their clock is real.
2026-07-26 18:00:08 +02:00
Marc-André Bombeck 48b8071a67 test(medications): pin the container-correction wire contract
Submitting the correction dialog is out of reach for the SSR component
tests, so the two behaviours that only show up on submit are pinned against
the module source: a date rides along only when it changed, and a cleared
field sends null rather than an empty string. The route side of the same
contract is covered by a real request test.
2026-07-26 18:00:08 +02:00
Marc-André Bombeck fd1464867d fix(medications): separate the two container dates and name the ampoule caveat
The row printed the opening date and the printed expiry with nothing between
them. It carries the same middle dot the unit figures use.

The docblock now says out loud what the enum cannot distinguish: AMPOULE
covers both a punctured multi-dose vial, where the post-opening window is
real, and a carton of single-use glass ampoules, where it is not. The kind is
never auto-selected, so picking it is a statement about an opened container,
and the opening date is editable when it turns out not to be.
2026-07-26 18:00:08 +02:00
Marc-André Bombeck a4ff11f10d fix(medications): surface and correct the dates that decide a container's state
The supply tab rendered a container's kind, its unit count and a state badge,
and nothing else. A row could read "Expired" with nothing on screen to say
which date caused it, and the correction dialog only ever offered the
remaining-unit count — so the opening date, which the intake consumption hook
writes without the user ever typing it, could not be taken back.

Each row now carries the two dates it has, and the correction dialog edits
both alongside the unit count. Each field rides along only when it actually
changed, so saving a stock correction never rewrites a date left untouched,
and clearing the opening date sends null, which the route reads as "this was
never opened" rather than "leave it alone". Beside the opening date the
helper text says whether the kind carries a 30-day window at all.

The headline stops contradicting itself. "0 of 0 doses remaining" next to
"+ 54 units expired" read as "nothing was ever recorded" beside "stock was
written off"; when the available pool is empty and only expired stock is
left, one line says so instead. The low-stock push follows: no usable supply
with written-off units on hand is a different errand from an empty pack, and
`expiredUnits` now reaches the payload, the notification metadata and the
wide event.
2026-07-26 18:00:07 +02:00
Marc-André Bombeck bb1e01e456 fix(medications): restore the containers the in-use clock wrongly wrote off
Migration 0276 reverses the rows that reached EXPIRED only through the
post-opening clock on a container that never had one. It moves a row back to
IN_USE when an opening date is recorded and ACTIVE when none is, and sets
`expires_at` from the printed expiry — which for these rows is usually
nothing at all, because they recorded none.

The predicate is exact rather than approximate: nothing but the printed
expiry and the in-use clock can write EXPIRED, so a clock-less container
whose printed expiry is absent or still ahead can only have got there through
the clock. Pens and ampoules keep their EXPIRED, so does anything whose
printed date genuinely lapsed, and so does a drained container. No date is
invented and no printed expiry is fabricated.

The integration test seeds the before-state, applies the migration SQL from
disk, and asserts both what moves and what stays — the testcontainer starts
empty, so the migration running at boot proves nothing on its own.
2026-07-26 18:00:07 +02:00
Marc-André Bombeck a1dbd9c831 fix(medications): apply the post-opening clock only where a container has one
A supply container went EXPIRED 30 days after its first use no matter what
kind of container it was. That window exists because puncturing an injection
pen exposes its contents and the product information caps how long the rest
stays usable; a blister of tablets has no such limit, every tablet sits in
its own sealed cavity, and pressing the first one out says nothing about the
rest. The state machine never read `containerType`, so a tablet pack opened
five weeks ago had its remaining stock written off.

The clock now keys off the container kind. PEN and AMPOULE carry it: both are
a single sealed reservoir that the first dose breaches. BLISTER, INHALER,
BOTTLE and OTHER do not: their units stay individually sealed, or the
arrangement is simply unknown, and a fixed 30 days would be a number nobody
entered. The printed expiry still applies to every kind, so an unknown
container is not an unbounded one.

`computeExpiresAt` takes the kind too, so a clock-less container with no
printed date now persists no `expiresAt` at all and the daily expire scan
never sees it.

The per-item PATCH gains a way back out: `markAsFirstUseAt` accepts null and
clears the opening date, which is the correction a container needs after the
intake hook auto-opened it from a back-dated dose.

Also drops `daysRemainingInUse`. It had no caller anywhere in the tree and
its docblock described a chip that does not exist.
2026-07-26 18:00:07 +02:00
Marc-André Bombeck c941e7e5cb test(e2e): measure the heading caption atomically
The wrap assertion gated on the section heading, but the signals card paints a
skeleton heading first and swaps it for the resolved one — so the element could
detach between the visibility check and the measurement, and the skeleton
heading carries no action slot to measure in the first place. Gate on the
caption itself and read both rects in a single evaluate.
2026-07-26 17:44:15 +02:00
Marc-André Bombeck 29188b2262 fix(insights): let a section heading's trailing caption wrap instead of widening the page
The Insights overview panned sideways at every phone width. SectionHeading
pinned its trailing action slot with shrink-0, which sizes a flex item to its
max-content width. That slot was designed for a compact control, but every
caller passes prose today — the provenance method caption, the device-score
subtitle, the sleep-quality subtitle. A sentence at max-content measures
1229 px in English and 1464 px in German, and since the row sits inside the
AuthShell <main> — whose overflow-y:auto makes overflow-x compute to auto —
that width turned the page into a horizontal scroller. min-w-0 lifts the flex
min-width:auto floor so the caption wraps to the row instead; the parent's
flex-wrap still moves the slot to its own line before anything is squeezed.

The existing width guards could not see this. Both measured
documentElement.scrollWidth, which never budges when <main> absorbs the
overflow into its own scroll area, and the insights case ran against an empty
mocked payload that fits any viewport by construction.

- Add e2e/utils/horizontal-overflow.ts: asserts the document AND the <main>
  scroll container, and names the widest offending element on failure.
- Add e2e/mobile-horizontal-overflow.spec.ts: sweeps /insights, /, /measurements
  and /medications at 360/390/393/402/430 px against populated fixtures, plus a
  canary proving the measurement can see through <main>, and a check that the
  insights fixture really populates the page.
- Rewrite the cycle spec's empty payload as a populated one and sweep every
  phone width; its /insights case moves to the new spec.
- Point mobile-density.spec.ts at the shared assertion — it carried the same
  document-only blind spot.
2026-07-26 17:44:15 +02:00
Marc-André Bombeck 78644ab77e test(ui): type the about-me mock and drop the es2018-only regex flag 2026-07-26 17:26:56 +02:00
Marc-André Bombeck f5f7d7d9d8 test(ui): a destructive control cannot ship without a confirmation path
Three legs, none of which stands alone.

`destructive-control-guard.test.ts` is the structural one. A registry
names every destructive control, what it takes, and how recoverable that
is; three detectors then make FORGETTING to register the failure mode.
The obvious guard — every trash icon must sit near a dialog — fails both
ways and fails quietly: it misses the control that destroyed the most
here, a PUT carrying two empty strings with no trash icon involved, and
it cries wolf on the trash icon that drops a row from an unsaved draft.
Both classes are live in this tree, and a guard suppressed on half its
hits teaches people to suppress it. So the judgement lives in the
registry, where it is written down and reviewable, and the mechanism only
guarantees that a new control cannot arrive unnoticed and a removed one
cannot leave a lie behind. What it cannot do is on the record in the
docblock: it cannot prove the registry complete, and it cannot prove an
entry true. Skipping a confirmation is possible, but it costs a written
argument, and the length floor is enforced.

`confirm-button.test.tsx` proves the dismissal contract once, where it
belongs: the trigger carries no click handler, the caller's callback
reaches exactly one node, the dialog action, and cancel never sees it.
An earlier draft also asserted that a closed dialog renders no confirm
control; that passed with `open` hard-coded true, because Radix portals
do not render server-side at all. It was measuring the renderer, so it
is gone rather than left green.

`about-me-clear-scope.test.tsx` covers the finding itself: the request
carries no allergies key at all, the clearing call reaches the control
only as `onConfirm`, and the allergy field stays editable so the targeted
way to clear it survives.

The existing settings suite grows to name every newly guarded control by
slot, plus the threshold override switch, whose off position performed
the same irreversible delete as the Reset button beside it.

Every assertion was mutation-checked.
2026-07-26 17:26:56 +02:00
Marc-André Bombeck 48ac8f7e2d fix(ui): a mis-tap must not be able to destroy an allergy list
Settings → AI "About me" had one Clear control wired to
`save.mutate({ aboutMe: "", allergies: "" })`. One tap, no dialog, and
the allergy line went with the note — while the toast it raised said
"About-me note cleared", which is what the control looks like it does.
The allergy line is the first thing a clinician reads and the column is
overwritten in place, so there is nothing to restore afterwards.

Scope the control rather than only gating it: it now writes `aboutMe`
alone and omits `allergies`, which the PUT leaves untouched by contract,
so the payload cannot carry the allergy line away even if the dialog is
ever dropped. Clearing allergies stays possible in the allergies field
itself, which is a deliberate act on the thing being emptied. The
confirmation on top guards the note.

The same one-tap shape ran through thirteen other controls that
overwrite a column or drop a credential with nothing to restore from.
They route through the shared confirmation now: both Codex disconnects
(personal and the instance-wide shared account), the AI server key, the
profile photo, the threshold reset — all, per metric, and the override
switch, whose off position performed the same irreversible delete from a
control that reads as a display toggle — the source-priority order, the
dashboard layout, the Insights overview, a single target range, a
remembered Coach fact, a past Coach plan, a logged cycle day and a custom
cycle symptom.

`ConfirmButton` moves to `components/ui`, since it is no longer a
settings-local helper, and gains `ConfirmDialog`: the same dialog without
a trigger, for destructive controls that are not buttons. A switch has to
stay on when the dialog its off position raised is cancelled, and a
control that could not reach for the primitive reached for nothing —
which is a fair part of why those were the ones that shipped unguarded.

Copy lands in all six locales.
2026-07-26 17:26:56 +02:00
Marc-André Bombeck 0afd217f9f docs(api): say which profile fields an empty string actually clears
The description promised that null or an empty string clears any field.
An empty string clears the free-text ones and now gender, but email is
validated as an address and 422s on it, and the display preferences are
not clearable at all. Name them rather than let a client find out.
2026-07-26 17:09:03 +02:00
Marc-André Bombeck 349dac3a07 test(profile): cover the third gender value where it used to be lost
Adds the cases that were missing everywhere except the FHIR conformance
suite: an empty string clears without taking its siblings down, OTHER
round-trips through the profile and onboarding writes, an unsupported
value is still refused and says which field it is about, the Strain
model scores an unanswered account on neither side, the dashboard bands
give OTHER the neutral fallback, the snapshot payload carries the value
instead of flattening it, and the doctor-report cover omits the row
rather than claiming a value the account never chose.

Refs #645
2026-07-26 17:09:03 +02:00
Marc-André Bombeck f74efc44aa fix(profile): let the third gender value through every surface that handles it
The profile validator has accepted MALE, FEMALE and OTHER for a long
time, but the published contract documented two, and a client written
against that contract had no way to know the third existed. The profile
PATCH is one write, so a rejected gender took every sibling field with
it - a time-format change lost to a field the person never touched.

Gender now clears on an empty string, the same contract every other
profile field documents, and an unsupported value is refused with a
message that names the field and its accepted values instead of the
validator's own prose. Onboarding validated against a narrower enum
than the profile schema and 422'd the whole submission; its form could
not record the third value at all.

The rest is the same defect in other clothes. The Strain model scored
an unanswered or OTHER account on the male Banister coefficients; it
now uses the midpoint of both published models, matching every other
sex-split formula here. The dashboard snapshot emitted null for an
account that answered OTHER, which is indistinguishable from no answer.
Three casts asserted a two-value type over a three-value column. All of
them now carry the stored value and narrow once, at the boundary of a
reference table that genuinely has no third row, through one named
helper in src/lib/profile/sex.ts.

Refs #645
2026-07-26 17:09:03 +02:00
Marc-André Bombeck 0ce510fa68 refactor(sleep): pin the in-bed legend before the per-stage loop reads it
The envelope was assigned after the loop, so the loop's IN_BED skip had
no observable effect - removing it changed nothing, which makes it a
dead branch rather than a guarantee. Assign the envelope first and let
the skip protect it.
2026-07-26 17:09:03 +02:00
Marc-André Bombeck a3b0af577f fix(sleep): count a re-exported sleep period once
A device can write the same period twice: an original envelope and a
corrected one, same start, later end, distinct external ids. Both rows
belong to the same writer, so the writer collapse keeps both, and the
per-stage totals added them. One night served a 10 h 13 min in-bed
headline next to a 20 h 17 min in-bed legend on the same screen, and
the hypnogram drew the night twice over.

Every per-stage total is now the measure of the union of that stage's
spans rather than the sum of the rows, and the same merged spans feed
the hypnogram segments and the time-asleep headline. Stages merge
independently: two stages describing one minute is a contest, and the
writer collapse is this file's mechanism for that - arbitrating again
would collapse the summary-shaped writers, which stamp every stage
total on the sleep end instant and nest by design.

stages.IN_BED is now the in-bed envelope itself, the same union
inBedMinutes reports, so the legend cannot contradict the headline and
a night whose envelope only a losing writer recorded still reports it.
2026-07-26 17:09:03 +02:00
Marc-André Bombeck 81ba711946 chore(db): number the account-scoped import migration after the ones ahead of it 2026-07-26 16:37:21 +02:00
Marc-André Bombeck b627b92809 feat(medications): rule on each export status for what it actually is
The seven documented statuses are four kinds of thing, so they get four
rulings instead of one.

Taken and Skipped state a decision about a dose and each maps onto something
already recorded here — a row with a take time, and a row marked skipped. That
second mapping was checked rather than assumed: skipped is documented as a
deliberate skip and held apart from autoMissed, which is the marker for a dose
nobody ever acted on, and the compliance re-fold counts a skipped slot in its
own column and never as taken. So a deliberate skip arrives as one.

Not Interacted, Not Logged and Unspecified are absences rather than
statements. Written as taken they would invent a dose; written as skipped they
would invent a decision, because not answering a notification is not choosing
not to take something; written as pending they would put outstanding doses
from years ago in front of someone today. Snoozed describes what happened to a
reminder. Notification Not Sent is about the software. None of the five
becomes a row.

All five are refused, but each under its own name, because "the file says
nothing about this dose" and "this row is about a reminder" are different
things and a person acts on them differently. Three reasons where there was
one, each with its own sentence in six locales.

An inactive medication's history imports and the medication stays inactive.
Preserving what happened and putting a medication back on the active list are
two acts, and only the first belongs to an import.
2026-07-26 16:37:10 +02:00
Marc-André Bombeck 36d589e11b test(medications): close the gaps the mutation pass found
Three things the tests said were covered and were not.

A column flipped from honoured to ignored moved both sides of its own
assertion, because the check read the verdict off the table it was checking.
The verdicts are now written out as literals, so changing one is a decision
somebody states twice.

The preview block was unreachable from an SSR-only test, so nothing would
have noticed it rendering through the outcome view — which would classify
what a preview *would* write as a success and put a tick on a run that never
ran. It moves into its own component that takes the verdict as a prop, the way
the CSV result view already does, and the preview and the archived-row report
are asserted directly.

The account-wide import narrows its in-progress check to account-wide jobs, so
an import running on one medication's page does not refuse it. Nothing pinned
that; now something does.

Also correct two comments about the earlier replay-key fault. The repeated
value in the reported file was not a dose quantity — it was copied from the
example this dialog itself displays. The field never carried identity, and the
submission was never at fault.
2026-07-26 16:37:10 +02:00
Marc-André Bombeck 8a36117a0c feat(medications): show what the importer does with each column
The dose-history import gets its own card in Settings, because the file
covers a whole regimen and the per-medication dialog on a medication's page
covers one medication.

Preview before writing. The dry run parses the file, matches every row
against the record, and answers with the whole verdict: rows read, rows that
would land, rows refused grouped by reason, and the medication names that
match nothing — verbatim, so they can be added and the file re-run. Nothing
is written until the second button.

The column rulings sit on the card before a file is chosen, one line each,
naming whether the column is used, reported, or not read and why. A column
read and dropped in silence is indistinguishable from a column honoured, and
that is how somebody comes to believe an importer looked at something it
never did.

Outcomes render through the same primitive the other three import surfaces
use, so a run that wrote nothing cannot carry a success tick. The preview
borrows the reason lines and deliberately not the outcome sentence: a preview
has written nothing, and classifying it by the counts it would produce would
put a tick on a run that never ran.
2026-07-26 16:37:10 +02:00
Marc-André Bombeck 34a220421c test(medications): prove the reported export lands, row for row
The fixture is the export a self-hoster attached, de-identified as it
arrived: 3,395 rows, sixteen medications, three and a half years, two UTC
offsets, three statuses, two units, blank cells in four columns. A
hand-built fixture would have carried one status and one offset and proved
nothing about the file that prompted the work.

The integration pass runs it through the real derivation into a real
Postgres, because the claim is about rows: 3,387 land, 8 are refused because
the file records no decision about them, and imported plus refused equals
every row read. 2,799 of the doses were taken after they were due, which is
the fact that survives only because the scheduled slot and the take are two
columns.

Structural cover so the verdicts cannot rot: every column of both shapes has
exactly one ruling, and every parse-time refusal is spelled the same as a job
skip reason — a reason that exists in only one vocabulary renders as "reason
not recorded", which is a refusal nobody can act on.
2026-07-26 16:37:10 +02:00
Marc-André Bombeck 11e6402b5a feat(medications): read a dose-history export directly
The intake importer took a hand-written list of dates and times for one
medication. A self-hoster with a ten-column export covering sixteen
medications and years of history was left translating it by hand, which is
where the scheduled time and the time a dose was actually taken get
flattened into one — the distinction the record wants most.

The export keeps them as two columns, so read the file's own shape.
`MedicationIntakeEvent` already models that pair as `scheduledFor` +
`takenAt`; an imported dose now populates both instead of anchoring itself
on the take. `Skipped` becomes a deliberate skip with no take time, which is
how every other skip path writes it. A dose whose actual amount differs from
the amount the schedule expected carries the difference as a per-dose
override; an unremarkable amount stays absent, which is what absent means
there.

Timestamps carry their own UTC offset (`+1030` and `+0930` on adjacent rows
of one file) and it is read literally: the epoch is computed from the stated
offset rather than handed to the platform's lenient date parser, and a value
without an offset is refused instead of assumed to be the server's zone.

An export covers a whole regimen, so the job is account-wide and each
payload entry names its own medication — migration 0276 widens
`medication_intake_import_jobs.medication_id` to nullable for it. The
per-medication route is unchanged and keeps naming its medication on the job
row. A medication is never created from an import file; unmatched names come
back verbatim so they can be added properly and the file re-run.

Every column gets a verdict, and `?dryRun=1` returns the whole one before
anything is written.
2026-07-26 16:37:10 +02:00
Marc-André Bombeck 69d9a164f7 docs: realign the notes table after the count changed width 2026-07-26 14:54:18 +02:00
Marc-André Bombeck 3967f886f8 docs: correct the model count the contributor notes carry 2026-07-26 14:52:37 +02:00
Marc-André Bombeck f0c5b5ae94 fix(medications): identify an imported dose by when it was taken
The intake import built its replay key from the row's optional `zaehler`
field. That reads as a per-row counter — the documented example showed one —
but nothing required it to be unique and nothing checked. A reported export
carried the dose quantity there instead: 1 tablet, on all 28 rows. Every entry
queued under the same key, one dose reached the ledger, and the other 27 came
back reported as duplicates of a dose they had nothing to do with.

The key is the instant now, and `zaehler` is out of the contract rather than
accepted-and-ignored. Nothing is lost by dropping it: the live-row unique on
`(user_id, medication_id, scheduled_for, source)` already makes an imported
dose unique per instant, so a re-import of a file first imported under the old
key still dedups.

The telling is the other half. Two different facts were counted as one number:
entries of a submission that collide with each other, and doses the ledger
already holds. They are separate reasons now, grouped with a count, and the
dialog renders them through the same classifier and the same outcome line as
the CSV and JSON import cards — so an import that wrote nothing can no longer
answer in success tone. That rule shipped for the settings import panel and
this surface was not part of it; the guard now lists every import surface by
path, so a fourth one has to be added deliberately. `skippedInvalid` is gone:
it was hardcoded to zero on every path and reported a capability the importer
never had.

Alongside it, the other half of the environment report — a scheduled job that
fails every run has to say so. `recordError()` increments one unnamed counter,
so the admin status page could show a number and not which queue produced it,
and the module's own screen showed a zero day count with nothing to
distinguish "nothing stored yet" from "every run failed". pg-boss has held the
answer the whole time: a job that exhausts its retries keeps its queue name,
its error and its timestamp. The admin status now names the failing queues
with counts and the last error, the environment overview reports its own
account's failed runs, and neither needed a change at any of the thirty
`recordError()` call sites — a visibility fix that has to be remembered per
call site is the same failure one level up.

Refs #650, #646
2026-07-26 14:37:57 +02:00
Marc-André Bombeck a8d798442f fix(environment): name the enum type the database actually has
Migration 0217 created the type as `environment_location_source`; the
datamodel declared `EnvironmentLocationSource` with no `@@map`, so the client
compiled every write against `public."EnvironmentLocationSource"`. Postgres
answered 42704, the upsert rolled the transaction back, and the module sat at
"0 days recorded" from the day it shipped — one enum out of forty-nine, and the
only one without a map.

No migration follows. `prisma migrate diff` against a database built from the
committed migrations reports the enum rename and the `environment_contexts.source`
column rebuild before the map and nothing after it: the database was never the
side that drifted, and renaming a live type to match a datamodel typo would be
the wrong direction.

Two guards, because the two questions are different. The unit guard replays
CREATE TYPE / ALTER TYPE RENAME / DROP TYPE across the migration set and holds
every enum to a type that survives it. The integration guard compares all three
name classes Prisma emits — enum type, table, column — against a real migrated
Postgres, which is the only place a name mismatch is observable at all: it
survives generation, typecheck, and every mocked test untouched.

Refs #646
2026-07-26 14:37:57 +02:00
Marc-André Bombeck 054eb51e96 chore(db): number the failure-streak migration behind the ones already released 2026-07-26 14:19:26 +02:00
Marc-André Bombeck e0ae866f32 test(whoop): assert the per-resource failure carries its leg 2026-07-26 14:19:13 +02:00
Marc-André Bombeck 4e3b029c38 test(integrations): pin the escalation ladder, leg-scoped clearing, and what the card says
Mutation-checked against the production paths they cover: the escalation
verdict, the leg guard in `recordSyncSuccess`, the streak anchor the verdict
dates itself by, the status envelope threading it, and the pill's relative
timestamp.

Also give the four legacy per-provider status routes the verdict the
consolidated envelope already carries. They answered `connected: true` plus a
`state` string and nothing else, so a client reading one of them saw a live
connection over a pipe that had delivered nothing for weeks.
2026-07-26 14:19:13 +02:00
Marc-André Bombeck 527dd6c1fa fix(integrations): escalate a long transient failure streak, and scope a success to its own leg
A sync failure the classifier bucketed as `transient` had no exit. The park
test read `kind === "persistent"` and the persistent streak anchor was only
ever stamped by a persistent failure, so a streak could run for a thousand
attempts with the persistent counter at zero, the state frozen at
`error_transient`, and no ladder it could climb. `transient` is a hypothesis
about retryability, and the classifier defaults every timeout, 429, 5xx and
unrecognised response to it precisely because the wire cannot tell a reboot
from a permanent break. That default is right for one attempt and wrong for a
thousand: an unbroken streak retried hourly for a full day has refuted it by
experiment. Record it as `persistent` from there, which feeds the park ladder
that already exists — 48 h of unbroken failure end to end before an
integration stops attempting.

The counters could not accumulate anyway. `IntegrationStatus` is keyed
(user, integration) while Withings runs four legs on four crons and WHOOP four
resources on four more, and a success cleared the error, every bucket, the
streak anchor and the state whatever leg had recorded them. The ECG tick at
:41 wiped what the sleep tick recorded at :15 — including a reauth park — so
the strike ladder was reset several times an hour and the card painted a green
"connected · 12 minutes ago" over a data class that had stopped arriving. A
success now clears only what it is entitled to clear.

Two columns carry it: `failing_since_at` dates the streak, `failing_leg` names
its owner. The migration backfills the anchor for rows already in an error
state so a connection broken for weeks escalates on its next attempt rather
than restarting its clock.
2026-07-26 14:19:13 +02:00
Marc-André Bombeck 84e38ab110 chore(db): number the retention indexes for the slot they actually take 2026-07-26 14:04:56 +02:00
Marc-André Bombeck fb9a4ee616 test(sync): follow the tombstone purge's batched outcome shape 2026-07-26 14:04:44 +02:00
Marc-André Bombeck dccd2f320b test(settings): prove the wipe against real Postgres, and state what deletion means
The defect is about rows that survive, so a mocked client proves nothing. The
new integration test seeds a row into every table in the schema, driven by
information_schema and pg_constraint rather than a fixture, runs the wipe and
asks the database what is left. A table added later is seeded and asserted
without anyone remembering to.

It holds all four halves: nothing in the wipe list survives, everything in the
exemption list does, everything reached only by a database cascade is gone,
and the account row comes back reset but able to sign in. A second file pins
the allergy and checkup-reminder deletes actually removing their row.

The reproductive-health note gains a Deletion section. It taught people to
reason carefully about a host being imaged and never said what pressing
delete does; now it says which of the three deletions removes the row and
which only hides it.
2026-07-26 14:04:44 +02:00
Marc-André Bombeck 5e66ebaa54 fix(jobs): let the retention purges finish, and let them fail loudly
The nightly tombstone purge asked for `deleted_at IS NOT NULL AND deleted_at
< cutoff` and the audit-log purge asked for `created_at < cutoff`. Neither
predicate had an index behind it: every deleted_at index in the schema is
partial on IS NULL, the live-rows predicate, and every audit_logs index leads
with user_id or action. Both purges planned a sequential scan and then paid
index maintenance per removed row, in one unbounded statement, on a
connection carrying statement_timeout=60000. Past a certain backlog the
statement aborts, the transaction rolls back, nothing is purged, and the next
night runs the identical statement against the same rows. The mechanism that
gives deletion a horizon could not close on the instances that needed it.

Migration 0277 adds the indexes the predicates ask for; the three tombstone
ones are partial on IS NOT NULL, so on a healthy instance they cover only the
tombstones and shrink as the purge drains. The purges now walk the backlog in
batches of 5 000 by primary key, capped at 200 000 rows a run.

Both handlers used to swallow every error into a wide-event warning and
return, so pg-boss recorded a successful job either way: a purge failing
every night looked exactly like a purge with nothing to do. They now rethrow,
and a run that stops at the cap records that a backlog remains.
2026-07-26 14:04:44 +02:00
Marc-André Bombeck 0919d8b962 fix(settings): make Delete All Data delete what it says
The wipe deleted thirteen tables against a schema that carries more than
eighty user-scoped ones. Mental-health assessments, lab results, the
menstrual record, allergies, family history, the document vault, every AI
conversation and live clinician share links all survived a confirmation
that promised to permanently delete all health data. So did
MeasurementRollup, the per-day reconstruction of the measurements the same
transaction had just removed.

The list now lives in src/lib/data-wipe/wipe-plan.ts next to its own
exemption list, and a structural guard enumerates schema.prisma and fails
when a model belongs to neither. Adding a model can no longer widen the set
of things that survive a wipe by default. The same guard covers the User
row's own columns, which had drifted the same way.

The boundary is explicit: everything the account owns goes, except what is
needed to sign in again. The confirmation and the toast say so before the
person types the confirmation rather than after.

Checkup reminders and allergies promised permanence over a soft delete with
no restore path; both now hard-delete, so the dialog is true. The lab
biomarker dialog was checked and is already accurate — it maps to the
hard-deleting /api/biomarkers/[id] route, not to the soft-deleting lab
result route.
2026-07-26 14:04:44 +02:00
Marc-André Bombeck 601374313c docs(changelog): record the rounding fix in v1.32.39 and credit the reporter 2026-07-26 10:08:22 +02:00
Marc-André Bombeck 417288d3f5 Merge pull request #643 from MBombeck/release/v1.32.39
v1.32.39 — the doctor report carries only what was chosen
2026-07-26 01:25:15 +02:00
Marc-André Bombeck e67bb25338 chore(release): v1.32.39 — the doctor report carries only what was chosen 2026-07-26 01:12:03 +02:00
Marc-André Bombeck 84d78d2926 fix(units): round a converted value where the conversion happens
Every entry in the display-transform registry declares the decimals its
unit is read at. That number was carried to the render layer and read by
exactly one screen out of all the ones that show a converted value, so
everywhere else the raw product of a kilogram and 2.20462262185 reached
the surface. Pounds came out with a dozen digits it never needed.

Move the rounding into `applyDisplayTransform` and
`applyDisplayTransformDelta`, so a surface gets a correctly-scaled number
whether or not it remembers to ask. Patching the weight cell would have
left the same trap behind inches, Fahrenheit, miles per hour and miles.

Two boundaries stay exempt and are documented as such. A transform that
does not rescale is the exact identity and is never rounded, so a stored
78.45 kg keeps rendering as 78.45 on the metric branch and every
untransformed metric is byte-identical. `invertDisplayTransform` — the
entry boundary — keeps full precision, because a value rounded on the way
out and inverted on the way back would drift the stored canonical number
a little further on every save; the entry surfaces quantise to their own
two-decimal canonical dialect instead.

`applyDisplayTransformUnrounded` is the one documented escape hatch, for
arithmetic whose result is not rendered. The target adapter needs it: its
guardrails round inward, and rounding the 30 kg floor to the nearest
tenth of a pound first gives an edge that inverts to 29.98 kg and the
server rejects it.

The weight page and the HealthKit scaffold were folding the factor into
their stat strips by hand. Both now convert through the helper, so the
raw multiplier is only ever handed to the chart, which owns its own
series and formatting.

Formatting is unchanged. Precision is fixed at the conversion and the
surfaces keep the formatter they already used, so switching preference
changes the unit and nothing else about how the number looks.

Refs #627
2026-07-26 00:46:03 +02:00
Marc-André Bombeck 708846f72b chore(db): renumber the report-selection migration behind the glucose-context repair 2026-07-26 00:46:03 +02:00
Marc-André Bombeck bc1633db40 fix(export): make the standard report a click, not a preselection
The scope picker arrived with the standard doctor's report already ticked,
so someone who opened the panel and pressed Generate sent material they
never chose. Nothing is selected now until a person selects it.

The template keeps its definition and loses its automatic application: it
sits on a button at the top of the picker, named, on both surfaces that
mount the picker, and it stays there so the standard set can be restored
after experimenting. One click still gets a complete doctor's report.

Generate and the share-link create refuse an empty scope, with the scope
line beside them saying what to do rather than reporting a failure after
the press; the line also describes the control, so the disabled state is
not silent for a screen reader.
2026-07-26 00:46:03 +02:00
Marc-André Bombeck 59acbed5ab fix(i18n): deliver the locale catalog before the client bundle takes its first render
The root layout shipped the active locale's catalog as `<script defer>` and
counted on it executing ahead of Next's hydration scripts. It does not: Next
emits its own chunks as `async` tags AHEAD of anything the layout renders into
`<head>`, and an `async` script runs the moment its fetch settles. So the
370 KB catalog was racing the app bundle, and on a cold load it lost about a
third of the time — `load-locale` initialised with an empty cache, every `t()`
in the hydration render returned its raw key against the server's real text,
and React tore the tree down with a text hydration mismatch. The user saw a
frame of `nav.skipToContent` before the provider's backfill landed.

Register the catalog through `next/script` at `beforeInteractive` instead.
Next's own bootstrap awaits those scripts before requiring a single app module,
and pairs the registration with a `<link rel=preload>` so the download still
starts with the document rather than after it. The CSP nonce rides along, so
the posture is unchanged.

Fold the boot global into the cache lazily rather than snapshotting it at
module evaluation. The snapshot made correctness depend on an ordering the
module cannot see; reading on demand reduces the invariant to something local
and testable — whatever the boot script assigns is visible to the first `t()`
that asks, whenever it lands.

Measured on the dashboard render spec, 30 repeats per project against a
production build: 11 of 30 failed on desktop before, 0 of 60 after.
2026-07-26 00:46:03 +02:00
Marc-André Bombeck 7b56f9ddbd test(export): stop the Gesundheitsakte specs racing hydration
Everything on that page is server-rendered, so a row is visible long before
the client boundary that owns it is live. A chevron click landed on inert
markup about one run in four, and a `boundingBox()` occasionally came back
null because the node had just been replaced.

The page is one client boundary carrying the export panel and the sharing
card, so the sharing card's own query is a sound signal that the surface is
wired. One helper opens the page and waits for it; every case on that route
goes through it, including the disclosure case that was already doing this
by hand.
2026-07-26 00:46:03 +02:00
Marc-André Bombeck e9b7d5f885 test(export): point the export specs at the surface they mean
Three of the picker assertions resolved to two elements and failed strict
mode; they now name the export mount, and a new case pins that each mount
carries exactly one of every id the picker renders.

The included-data disclosure case asserted a collapsed first render. The
panel opens on a first run on purpose — that is the only place the standard
template is applied, and a template behind a closed disclosure is opt-out
wearing a hat — so the case now asserts the contract that replaced it: open
on first render, and still a disclosure, closing and reopening under the
same control.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck fcd3dd2d07 fix(export): make each scope-picker mount addressable on its own
Settings → Gesundheitsakte mounts the one scope picker twice: the export
panel at the top, the share-link create form under `#sharing`. Sharing the
component is deliberate, but it also meant every test id inside it resolved
to two elements on that page, so nothing in the picker could be addressed
without guessing which one you got.

The picker now takes a required `surface` and every id under it ends with
that surface — `report-group-row-vitals-export` and
`report-group-row-vitals-share` are two controls and read as two controls.
The summary line beside each picker follows the same rule. A third mount
picks a value and stays addressable by construction.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck 2f92c0577b chore(db): renumber the report-selection migration after 0273 landed on main 2026-07-26 00:45:08 +02:00
Marc-André Bombeck ffe99caccd fix(fhir): stop advertising a credential no client can obtain
The capability descriptor named `fhir:read` as the scope a narrow token needs
for the REST face. No code path mints a token carrying it — every mint grants a
wildcard, the medication ingest scope, or an MCP scope — so the face is
reachable by the owner's cookie session or a wildcard device token and by
nothing else. Advertising an unobtainable credential invites a client to go
looking for one. The constant stays where it belongs, as the fail-closed check
each route makes; the descriptor now reports the fact instead.

End-to-end coverage for the scope picker: the first run opens it expanded with
the fenced tier rendered and no group control over it, a group row clears the
44 px floor at 390 px, and a leaf grid opens from its chevron. All on stable
test ids rather than text a breakpoint can hide.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck f12290532f fix(sharing): let the owner replace a link the upgrade closed, and count only what is shown
Migration 0273 revokes every live record share link minted before the selection
model, because their frozen scope was "everything except mood and cycle" — a
scope nobody chose. The owner has to see why the link stopped and be able to
replace it without retyping what they already decided, or a deliberate closure
reads as a fault. The list now names that state, says why in one sentence, and
offers a replacement that carries the label, the window and the lifetime
across, leaving exactly the decision that link never carried.

Writing the test for it found a defect in the picker. The group count and the
group checkbox read the catalogue's group rather than the leaves the surface
actually renders, so on the share-link form — where the insurance leaf is
hidden because the server refuses it — the identity row read "1/2" and drew
itself half-selected over a leaf the owner could neither see nor reach, and its
checkbox would have tried to add it. The three helpers take the rendered leaves
now, so a control cannot describe more than it offers.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck 9e68ff525f feat(share): serve the record a link already implies, and say when there is none
Two downloads under the token — the FHIR document Bundle and the clinical PDF —
behind the same unlock cookie the document route enforces, serving exactly the
link's frozen selection. That is what the removed FHIR toggle promised, in the
form a practice actually uses: open the link, unlock it, save the file. No new
credential type, no new scope, no mint path that does not exist.

The gate is the document route's, step for step: the same live check, the same
token-scoped cookie, the same blunt 404 for every miss class so a probe cannot
tell "no such link" from "revoked" from "documents-only". It differs in one
number, because generating a bundle is far more expensive than serving a stored
blob and must not share a budget with it. Each download writes its own audit
row. The scope cannot exceed the page's: both resolve the same frozen leaf list
through the same resolver, and the insurance leaf is refused at create, so the
insurance number cannot reach this path at all.

The MCP doctor-visit summary stops claiming presence it cannot support. An
account whose owner never saved a report selection resolves to the empty one on
that surface, and an empty scope produces an empty payload — which now reads as
{ present: false, reason }, the same sentinel the other reads use, instead of a
record with every section quietly missing.

Mutation-checked: removing the unlock check, the rate limit, the documents-only
guard or the audit row reddens its own case; pointing the bucket at the document
route's key reddens the bucket assertion; making presence unconditional reddens
the absence case.

The saved profile's shape moves out of the module that reads the row. The panel
is a client component and parses the blob from the /me payload; importing the
reader dragged the database client into the browser bundle, which typecheck and
the unit suite both pass and only `next build` catches.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck f42934487d test(export): prove the scope holds at the artefact, not just at the switch
Five layers, because the defect had five places to hide.

The catalogue guard checks what the compiler cannot state: every leaf resolves
a label a human can read, every group resolves one, the fenced tier stays last,
and the shipped template intersects the fence at nothing.

The selection guard pins the three minting functions against each other. A
stored blob this build cannot read resolves to the empty selection, never to a
default set — that is what makes the revoked legacy links revocable rather than
silently re-scoped. A request naming an id the server does not know is refused
by name rather than dropped, because dropping it would narrow a scope the
caller did express.

The egress guard freezes the set of aggregator callers with the source of each
one's selection written down. The required brand-typed parameter already stops
a new path compiling without an answer; this stops one being added without a
human writing the answer down, which is the moment a reviewer asks the
question.

The behavioural sweep aggregates every leaf in and out against a fixture that
carries a row for each, and asserts the payload differs. It is parametrised
over the catalogue, so a leaf added later is covered the day it is added. Its
positive control asserts the all-on payload is genuinely populated first, so a
"no difference" result means the gate is inert and not that the account is
empty. Beside it: an unmapped measurement type is excluded rather than passed
through, no table behind an unchosen leaf is queried at all, and an empty
selection produces an empty record.

The artefact layer runs the real route and reads what comes out — the FHIR
bundle, the PDF text and the zip's contents — with a positive control beside
every exclusion.

Mutation-checked: making the gate admit everything reddens the sweep for every
leaf; passing an unmapped type through reddens the fail-closed case; dropping
the mood query's condition reddens the zero-read case; returning the BMI
unconditionally reddens the sweep naming BODY_MASS_INDEX; adding a fenced leaf
to the template reddens the purity guard; removing the panel's seeding block
reddens the first-run and repeat-run cases; dropping the insurance refusal
reddens the share-link case; adding a caller outside the frozen list reddens
the egress guard naming the file.

The panel suite grew past the practice name it was written for and is renamed
for what it now covers: the three states, the fenced tier having no group
control, and the scope line naming its sensitive inclusions.

Integration coverage moves to the leaf model, including the case this release
exists for: an account with data in every domain and a caller that named
nothing gets an empty record.

The OpenAPI contract catches up with the routes — the required selection, the
refused keys, the share summary's new fields, the moved `Patient/$everything`,
the Coverage search and the five typed reads.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck 582db8a538 feat(export): one grouped scope picker, on both surfaces that choose a scope
The export panel rendered twenty flat switches behind a collapsed disclosure,
and the share-link form rendered none at all — it posted whatever the create
schema defaulted to. Both now mount the same picker: twelve named groups, a
count on each, the leaf grid one chevron away, and a fenced tier below a
separator.

The fence is mechanical, not a label. The sensitive tier has no group
checkbox, so no single control anywhere in the picker can turn on more than
one of mood, cycle, family history, PHQ-9, GAD-7, WHO-5 or SCI at a time.
There is no select-all. The shipped template contains none of them. And the
scope line under the button restates every sensitive inclusion by name rather
than folding it into a count, because a number cannot be checked at a glance
and "incl. Mood, PHQ-9" can.

First run opens expanded with the named standard template applied and the
fenced tier visibly empty, so the user presses Generate having seen twelve
named groups. Every later run opens collapsed carrying their own last
selection, which makes the waiting-room path two interactions.

Only leaves are ever stored. A group's checkbox is derived from its leaves, so
the two cannot disagree, and switching a group off and back on inside one
session restores the pattern instead of flooding it.

The cycle export card is deleted. It posted empty groups under a docblock
claiming every other section was off, and empty groups resolved to the
defaults — so a "cycle only" download carried weight, blood pressure, pulse,
oxygen saturation, body fat, body composition, the medication list, adherence,
labs, allergies and family history. Cycle is a fenced leaf in the one picker
now, reachable for the PDF as well as for FHIR, which is more than the card
offered.

The panel gains a 30-day preset and a custom window, and its error line says
what went wrong instead of interpolating a status code.

The Checkbox primitive learns the indeterminate state it already had the Radix
support for. A partly-selected group drawn as unchecked would say "none of
this is included", which on this surface is a false statement about what
leaves the instance.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck 5e490f31ec feat(export): make the doctor report carry only what was chosen
The report resolved an absent key to "included". A caller that sent no
sections object, or an empty group inside one, received the full record at
server-chosen defaults: roughly fifty metrics plus the medication list, labs,
allergies and family history. The web panel sent every key explicitly so it
looked fine from the browser, but the share link, the FHIR REST face, the MCP
doctor-visit resource and the cycle-only export card all went through paths
where absence meant yes. No arrangement of booleans fixes a default that means
yes, so the shape goes.

A selection is now an inclusion list of leaf ids. A leaf is the smallest thing
that can be released or withheld: every measurement type, and thirteen
structured sections — identity, insurer, the glucose panel, labs, the drug
list, the administration ledger, adherence, the GLP-1 block, allergies,
illness episodes, family history, mood, cycle. Membership is inclusion,
absence is exclusion, and there is no defaults constant anywhere to drift.

Three locks keep a metric added later off a report chosen earlier. A stored
selection is a list of what was chosen, so a leaf that did not exist then is
not in it. The catalogue is keyed over the measurement enum, so adding a
member without deciding its group fails typecheck in the same commit. And the
aggregator's filter inverts: a type the catalogue does not know is excluded and
annotated, where it used to be passed through.

The aggregator's third parameter is now required and brand-typed, which turned
the three surfaces that had no scope at all into compile errors. Each one had
to name a source, and each now replays the owner's saved selection — the same
object reviewed in Settings. An account that never saved one gets nothing
there rather than a guess.

Identity and insurance become selectable, and a share link refuses the
insurance leaf outright. The share view never decrypted the number; that is
structural now instead of incidental. The KVNR is not even read unless the
leaf was chosen.

Two surfaces gain a record they always implied. A share link serves its frozen
selection as a FHIR bundle and as the PDF, behind the same unlock cookie the
document route uses, with its own rate bucket, its own audit row and the same
blunt 404 for every miss. That is what the removed FHIR toggle promised, in
the form a practice actually uses.

The AI summary leaves the report. Its text came from the cached briefing with
no relation to the selection, so its scope could not be proven, and prose whose
scope cannot be proven has no place on a document whose whole point is a proven
scope.

The PDF table stops being a nine-type whitelist. It prints every selected leaf
that has data, sub-headed by group, in the same order the panel shows — so
selecting resting heart rate no longer means seeing it in the bundle and not in
the PDF. The clinician view is grouped the same way and reads real labels
instead of "Phq9 score".

Migration 0273 adds the saved-selection column, drops the orphaned prefs column
and the two retired share-link scope columns, and revokes every live record
share link minted before this model. Their frozen scope was "everything except
mood and cycle", which is a scope nobody chose; narrowing it would still be a
scope nobody chose. Documents-only shares are untouched. The sharing list shows
each closed link in a pick-again state with its label, window and expiry kept.

The retired allowFhirApi and resourceTypes stay on the create and list
responses as the constant false and empty list. The request refuses them. They
come off the response once the native client confirms it does not decode them.

Six files are split at seams this change already touched: the aggregator into
its gate, its dense-bucket query, its series, its glucose panel, its medication
ledger and its clinical records; the export route's three emitters into their
own module; the clinician view into its sections, its document list and its
downloads.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck f90219784b test(export): pin the selection controls against going inert again
Two layers, because the defect had two halves and each needs its own kind of
proof.

The structural guard walks the panel's control shape, folds every control
through the wire schema and the resolver the route uses, and fails when
flipping one changes nothing in the resolved selection. It also checks the
panel renders a row for every control it declares, and that every resolved
flag is read downstream, either by gating measurement types or by name in the
aggregator. That is the link nobody had written: the dead switches were dead
for several releases and no test connected "the panel renders this" to "the
export honours this".

The behavioural half runs the real export route and reads what comes out. The
previously-dead measurement switches are asserted against the FHIR bundle and
the PDF table, with a positive control beside each exclusion so nothing passes
on an empty fixture. The screening totals are asserted absent for a caller
that names other sections and for a caller that sends no sections at all, then
present when asked for by name. Allergies and family history are asserted
absent from the bundle and never read from the database, and the package case
opens the zip and checks the bundle inside it agrees with the PDF beside it.

Mutation-checked: reverting the allergy gate, the medication-list gate, the
screener entries or the measurement entries turns the matching cases red and
leaves the rest green; removing a control's fold entry or its aggregator entry
turns the guard red with the control named.

The remaining changes update fixtures the new shapes touch: prefs objects now
resolve the added flags from the defaults, and the share-link fixtures drop
the retired FHIR scope, with a case pinning that the create route now refuses
those keys instead of ignoring them.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck 68b9903966 fix(export): make the doctor-report selection controls tell the truth
The export panel rendered twenty data switches and eight of them changed
nothing. The grouped selection folded down to twelve flat flags and dropped
oxygen saturation, body fat, body composition, resting heart rate, HRV, VO2
max, steps and distance on the way, so a user who switched any of them off
still exported the readings in the PDF, in the FHIR bundle, in the package,
through a clinician share link and over MCP. Each one now resolves to a flag
the aggregator reads, and the rows are excluded at the query rather than
fetched and dropped.

Three more controls were untruthful in their own way:

- The medications switch documented itself as excluding all medication data
  and excluded the adherence figures only. The drug list and the per-dose
  administration ledger rode along regardless. Both follow the switch now;
  the rows are still read, because compliance and the GLP-1 block are computed
  from them, and they no longer leave the aggregator.
- Allergies and family history were gated for the PDF and fetched
  unconditionally for the FHIR bundle, so a package could carry a compliant
  PDF and a violating bundle in the same zip. The reads are gated, and a
  deselected section is not read at all.
- The PHQ-9, GAD-7, WHO-5 and SCI screening totals had no per-report control.
  They rode the mental-health module alone, which has been default-on since
  v1.29.1, so depression and anxiety screeners reached FHIR bundles, share
  links and the MCP doctor-visit resource on accounts that never chose to send
  them. They get their own section key, defaulting off, with the module gate
  kept as the second lock, and the stale privacy-by-default comment that
  argued otherwise is corrected.

The share link's FHIR promise is removed rather than implemented. The create
form offered a toggle and a resource-type picker, the values were persisted
and badged on the finished link, and the API description called the toggle a
REST reachability switch, while no route has ever answered a share token with
FHIR or any other machine-readable format. Serving one is a feature with real
security surface and does not belong in a correctness fix. The toggle, the
picker, the badge and both claims are gone; the create schema rejects the two
keys instead of accepting and ignoring them; the columns stay, unread, so no
data is destroyed to undo a promise.

Two files are split along seams this change already touched. The
measurement-type gating maps and the filter that reads them leave the
aggregator for their own module, so the query gate and the output gate cannot
disagree; the export panel's control shape and the selection it builds leave
the component, so the selection contract can be extended without the
rendering. Three accepted-and-discarded wire keys (activity energy, GLP-1,
side effects) are dropped: nothing rendered them and nothing read them.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck b32d1be607 Merge pull request #642 from MBombeck/fix/csv-import-silent-skip
v1.32.38 — accept a blood-glucose reading that carries no context
2026-07-25 23:16:22 +02:00
Marc-André Bombeck a23ee61184 test(e2e): route cleanup happens by construction, not per spec
A spec that arms `page.route(...)` in a `beforeEach` and never drops the
handler races its own teardown. The context closes, an async handler is
still awaiting, its next call rejects with "Test ended.", and Playwright
attributes that to the test that had just passed. With `retries: 2` plus
`failOnFlakyTests`, which spec bites is a matter of timing.

`e2e/setup/test.ts` re-exports Playwright's `test` extended with an auto
fixture that unroutes every page and the context after each test, and
clears the page event listeners a spec subscribes to; `expect` rides
along so a spec needs one import. Auto means a spec author cannot forget
it and does not have to know it exists. Every spec now takes `test` from
there, and `e2e-route-cleanup-guard.test.ts` keeps the import boundary
closed with an allowlist that carries its reason.

The per-file `afterEach` on the cycle spec goes away — leaving both would
read as not trusting the fixture.

Not covered: a page inside a context a spec builds itself. Only
`share-documents.spec.ts` does that, and it owns those contexts.
2026-07-25 23:02:03 +02:00
Marc-André Bombeck c506b72002 test(e2e): drop the cycle route handlers before teardown closes the page 2026-07-25 22:34:03 +02:00
Marc-André Bombeck ec49157e85 chore(release): v1.32.38 — accept a blood-glucose reading that carries no context 2026-07-25 22:18:03 +02:00
Marc-André Bombeck 208a2248cd test(import): cover the HealthKit glucose shape against the relaxed constraint
A HealthKit blood-glucose sample carries no context either — the mapping has
no field for one — so the batch ingest was building the same row the old
CHECK refused. Fold that source into the case that pins the contextless
shape.

Refs #640
2026-07-25 21:57:06 +02:00
Marc-André Bombeck e43e758c8d docs(import): put a contextless sensor reading in the downloadable example
The example CSV is the file people copy. It showed a glucose row with a
FASTING context and nothing else, which is the shape a meter produces, not
the shape a continuous sensor produces. It now carries both.

Refs #640
2026-07-25 21:56:10 +02:00
Marc-André Bombeck 4e95bd28f8 fix(import): stop the Apple Health card ticking an archive that landed nothing
Same shape as the CSV and JSON cards: reaching the done state rendered the
success tick whether or not a single row was upserted. The archive's count
picks the tone now, and an archive that wrote nothing says so.

The clinical-record count stays out of that decision. Skipping
electrocardiograms and lab documents is a documented, deliberate exclusion,
not a refused row, so it must not read as a warning.

Refs #640
2026-07-25 21:45:10 +02:00
Marc-André Bombeck 0c9832eb8d fix(import): accept a blood-glucose reading that carries no context
A CSV import of a continuous-sensor export wrote nothing and rendered a
green success state. Three faults compounded, and the reporter took all
three apart before filing.

The parser refused every BLOOD_GLUCOSE row with a blank glucoseContext.
Behind it, the CHECK constraint from migration 0021 read both ways at
once: a glucose row MUST carry a context, every other type MUST NOT. The
second half is right and stays. The first was written when the only
glucose writer was the entry form, where a person picks fasting or
post-meal at the moment of the reading. Every machine writer that
arrived since has no such answer per sample, so the Nightscout pull, the
MCP log tool, the Telegram capture and both bulk importers were all
building rows the database then refused.

A missing context now stores as NULL, never as a fifth enum member.
Every per-context reader selects by equality against the four members,
so the reading stays out of the doctor report's fasting rows, the
matching FHIR observations and the per-context targets, while the chart,
the all-time summary and the clinical panel keep it in full. Hand entry
still asks for a context, because at that moment you know it.

The import cards now read the counts rather than the status code.
Nothing written is a failure, a mixed file is a warning, and the refused
rows are grouped by reason with a count instead of fifty identical
lines, with the per-line detail one disclosure away. Both cards render
through one outcome table, so a tick cannot reach a result that wrote
nothing.

Refs #640
2026-07-25 21:23:26 +02:00
Marc-André Bombeck 019ad52c0d Merge pull request #641 from MBombeck/fix/reject-unstable-external-ids
v1.32.37 — refuse an identity that cannot be stable, and scope the intake replay key to its owner
2026-07-25 19:27:04 +02:00
Marc-André Bombeck 132238fd05 chore(release): v1.32.37 — refuse an identity that cannot be stable, and scope the intake replay key to its owner 2026-07-25 19:14:30 +02:00
Marc-André Bombeck 18691d4550 fix(medications): scope the intake idempotency key to its owner
`medication_intake_events.idempotency_key` carried a GLOBAL unique from
0001_init. The value is client-supplied, so two people running the same
client can mint the same key — and on a global unique the second one is
simply refused by the constraint. Their dose is not written and nothing
tells them so. The bulk drain made it worse: its replay probe read the
key with no user scope, resolved the first user's row, and reported the
second user's entry as a duplicate carrying a stranger's row id.

Migration 0273 replaces the global unique with `(user_id,
idempotency_key)`, the grain every other client-supplied identity on the
table already uses. Both unscoped reads in the bulk drain now key on the
composite; the other four reads were already narrowed by user.

On existing data this is a relaxation, so nothing needs reconciling: the
old constraint is strictly stronger than the new one, and no pair of rows
can violate an index the previous one already forbade. The migration
still carries a reconciliation step for a database whose global index was
dropped by hand at some point. It never deletes or rewrites a dose — it
clears the dedup HINT on the later row of a colliding pair and keeps
every intake event, timestamp, attribution and inventory stamp intact.
The new index is created before the old one is dropped, so a failure
aborts with the old protection still in place.

A structural guard freezes the read side: no `medicationIntakeEvent`
query may filter on the key without narrowing by `userId`.
2026-07-25 18:41:40 +02:00
Marc-André Bombeck 15b5f6f92f fix(ingest): hold the stability floor for identities no request schema sees
The dedup key on the medication intake surfaces is not only `externalId`.
`MedicationIntakeEvent.idempotency_key` is a `@unique` column persisted
with the dose row forever, with no TTL and no sweep, matched by plain
equality on the per-medication POST, the bulk drain and the external
ingest bridge. That makes it an identity, not a replay token, and it
carries the same failure mode: a key that rotates per client launch never
matches its own earlier post, so every retry logs the dose again. The
blank case is sharper still — the column is unique table-wide, so an
empty key resolves to whichever unrelated row stored it first. Refuse
both shapes at the schema on the single-entry paths and per entry on the
bulk drain.

The MCP write path hashes a host-supplied key into the `externalId` half
of its dedup key, so the shape is laundered before anything downstream
could see it and the check has to run on the input. That closes a live
one: every tool handler falls back to `""` when the host omits the
argument, and `""` digests to a constant, so each keyless write after the
first reported `already_logged` and was silently dropped.

The HTTP `Idempotency-Key` header stays out, and now says why in the
guard: `IdempotencyKey.expiresAt` bounds it and the cleanup job sweeps
it, so it is a windowed replay token and rotation is its design.

Extend the structural guard with a provenance rule for identities that
never pass a Zod request schema — a spreadsheet column, an MCP tool
argument. Inside the import and MCP trees, a module handling an identity
must apply the floor itself or import a module that does. That is a real
chain rather than a list of known offenders: a new importer that mints an
`externalId` from file input and imports nothing checked fails it, with
no allowlist to escape into.
2026-07-25 18:07:13 +02:00
Marc-André Bombeck 6e4bcffadf test(ingest): prove the external-id floor cannot be reintroduced
Harden the structural guard so it reads the file body with its imports
stripped: grepping the raw source cannot tell a file that APPLIES the
check from one that merely imports it, so deleting the call while
leaving the import slipped straight through.

Add the partial-success case on a second batch envelope — the mood bulk
ingest echoes the offending entry back with its index, status and
reason while the good rows still upsert.
2026-07-25 17:31:39 +02:00
Marc-André Bombeck 2ab66ab2e3 fix(ingest): refuse an external id that cannot be a stable identity
An external id is half of every ingest dedup key — (userId, source,
externalId), (userId, externalSource, externalId), (userId, type,
source, externalId). Re-posting the same triple is idempotent and
returns the existing row, which holds only while the id is stable
across client launches. A client that serialised an opaque platform
object with its default object description sent a per-allocation
memory address instead, so no sweep ever matched its own earlier row
and each one minted a fresh record: a live instance grew 23 phantom
medications in a day, none of which could collect a dose, because the
dose side resolved through the same rotating string.

Add one shared classifier for identifiers that provably cannot be an
identity — an angle-bracketed object description carrying a hex
pointer, a bare 0x address, and a blank value — and wire it at every
surface that accepts a client-supplied externalId.

Single-entry creates refuse through the schema, so the client gets the
standard 422 multi-issue envelope with a message naming the shape it
sent and what to send instead. Batch and bulk surfaces classify per
entry and report the offending row as skipped with reason
unstable_external_id, leaving the rest of the payload to land: one bad
row must not stop a sync. Every refusal carries pinned wide-event meta
(count, shape, surface) and never the id itself.

A structural guard freezes both routes into the floor: a request-side
externalId declaration either applies the field check or carries a
marker naming the file that checks it per entry, and the guard verifies
that file really does. The backup-restore payload is the one exemption,
with its reason recorded — it replays ids the server itself wrote.
2026-07-25 17:21:00 +02:00
Marc-André Bombeck ef9891fec5 Merge pull request #639 from MBombeck/release/v1.32.36
release/v1.32.36 — make the loose-ends failure class hard to reintroduce
2026-07-25 16:41:38 +02:00
Marc-André Bombeck 7c66413c1d chore(release): v1.32.36 — make the loose-ends failure class hard to reintroduce 2026-07-25 16:24:29 +02:00
Marc-André Bombeck c2d6adaff5 chore(audit): sweep for write-only columns from the release battery
Whether a column has a reader anywhere in the tree is a whole-program property,
and grep is the only cheap approximation. Grep is unsound both ways here:
generic names match unrelated identifiers and read as consumed, while
consumption through a select-less read plus whole-row serialization reads as
unconsumed. A gate built on that would drift towards green and stop meaning
anything, which is the failure this whole line of work exists to prevent. So it
stays a review tool with a human reading the output, run from the deep-audit
step and never in CI. The script says so itself and prints the false-positive
sources under every run.

It also carries the convention that makes the tool useful: a change that adds a
column, payload field, or preference contains its consumer, or the schema
comment carries `@internal` for something deliberately never surfaced or
`@pending-consumer(#issue)` for one still coming. The second marker is
re-listed on every run, so a follow-up that never happened surfaces at the next
release audit instead of never. Recorded in the contributor notes alongside the
two questions that are exact enough to be gates.
2026-07-25 16:24:24 +02:00
Marc-André Bombeck 99b8dfd566 test(guards): bind the account payload and the backup select to their pairs
Every finding behind this work is a two-ended change whose ends shipped in
different releases with nothing holding them together, and every gate the repo
runs proves one end: typecheck proves the export exists, knip proves it is
imported, the OpenAPI check proves the schema matches. None of them notice when
the second end never arrives.

Two questions here can be answered exactly, so they become tests.

The account endpoint rides every app boot and carries the user's preferences.
Its field names are read out of the response literal and each one must be read
by a client module — property access or destructuring, comments stripped,
because a doc comment claiming a consumer is what the remembered practice name
had for its whole dead life. That field would have failed this on the day it
shipped. One field is allowlisted today with its reason written out: the
insurer institution number has no web surface at all, which the entry keeps
visible rather than papering over.

The measurement backup select is now a named constant, compared field by field
against the model in the schema. Both sides are literals, so the comparison is
exact — no name heuristics, nothing that can drift towards accepting. A column
added to the model and forgotten by the backup fails at the commit that adds
it, which is how the aggregation provenance went missing. Scoped to the one
model; extend it as each further model becomes backup-relevant.
2026-07-25 16:24:12 +02:00
Marc-André Bombeck d8a3c08e12 fix(i18n): stop one key template from whitelisting a whole namespace
The reverse-coverage guard asserts that every key in the bundle is reachable
from a call site. It had stopped doing that for most of the bundle. A template
whose only static anchor was a namespace root registered that root as a subtree
base, so a single per-provider OAuth key marked every key under `settings.*`
reachable, and the same shape did it for `measurementReminders.*`. A cache-key
string that happens to start with a namespace word had quietly done it for
`insights.*` too. The guard stayed green throughout, which is the part worth
noticing: a pair guard degrades into a one-end guard without any signal.

Extract the matcher into a helper with its own unit tests, and change what it
accepts. A template carrying both a literal prefix and a literal suffix now
records the pair and requires both ends to match, so the OAuth template covers
the provider keys and nothing else. A template with no suffix whose anchor is a
bare namespace no longer whitelists anything — it fails the guard by name, with
the two honest remedies in the message. The same door is used by the template,
assignment and concatenation arms, so none of them can reopen it. Template
bodies are read by scanning forward from a known opening backtick rather than
by pairing backticks across the tree, which was drifting and dropping whole
files out of the scan.

Three call sites build their keys whole instead of interpolating onto a
namespace root: the two measurement-reminder due helpers, the mental-health
option labels, and the OAuth error copy, which now names its per-provider base.
Two audit-log action names that merely look like keys carry a written
exemption; the API error catalog is a runtime lookup and is allowlisted with
its reason.

With the matcher honest, 149 keys had no call site left. They belong to an
explainer popover, a hypnogram, a passkey table and an insights hero that were
all rebuilt or removed, and they are deleted from all six locales. The
explainer guard stops pinning its title leaves, which no surface has rendered
since the popover went.
2026-07-25 16:23:58 +02:00
Marc-André Bombeck f9a5e6a11f Merge pull request #638 from MBombeck/release/v1.32.35
release/v1.32.35 — remember the practice name, keep the source provenance in a backup
2026-07-25 15:44:30 +02:00
Marc-André Bombeck fe2e20a14b chore(release): v1.32.35 — remember the practice name, keep the source provenance in a backup 2026-07-25 15:28:39 +02:00
Marc-André Bombeck 2a396dd23e fix(backup): carry the aggregation provenance through backup and restore
The disaster-recovery backup selected every Measurement scalar except the
aggregation fields, so a restore brought every `stats:` aggregate back with no
provenance and the Apple Health authority ladder came back flat: an export.xml
source-day estimate could then overwrite a native HealthKit statistic.

`aggregationProvenance` now rides the select, the disaster-recovery
serialization, the payload schema and the restore mapping, following
`externalSourceVersion` exactly — including staying out of the reduced
portable shape, which carries the human-readable subset only. The schema field
is optional, so every backup written before this parses unchanged.

Restoring one of those older backups no longer recreates the ambiguity
migration 0263 repaired: an Apple Health row under a `stats:` identity with no
provenance comes back as LEGACY_UNKNOWN, the sentinel the cumulative drain
already writes in that situation, so a native sync or archive reprocess can
repair it. The gate is scoped as 0263 scoped its backfill — other providers
mint `stats:` identities for their cumulative dailies and have never carried
provenance, and ordinary point rows keep NULL.
2026-07-25 15:20:47 +02:00
Marc-André Bombeck f8f460bb66 refactor(measurements): drop the write-only aggregation columns (migration 0272)
Migration 0263 added three columns beside the Apple Health `stats:*`
authority ladder. Only `aggregation_provenance` is ever read back: the
external-measurement reconciler selects it and refuses a lower-authority write
against a higher-authority canonical row, and the cumulative drain reads it
too. That column stays.

`aggregation_contributor_count` and `aggregation_selected_source_hash` are
written by three call sites and read by none. No select, no raw SQL, no export,
no backup, no API contract, no repair path. The import-time selection keeps its
deterministic tiebreak, which compares an in-memory map key computed per flush
rather than a value read back from the table.

Both are plain nullable scalars with no index, unique, check or foreign key, so
the drop is a catalog-only operation that touches no measured value.
2026-07-25 15:03:44 +02:00
Marc-André Bombeck a3f1d780e7 feat(export): remember the practice name between doctor reports
The doctor-report contract has always described the practice / clinic name as
a stored preference, and `/api/auth/me` has returned
`User.lastReportPracticeName` since it shipped, but nothing ever wrote the
column. Every report asked for the clinic name again.

The export route now persists the sanitised name after the requested artefact
has actually been produced, so a generation that throws cannot overwrite the
remembered value with a half-typed one. Clearing the field persists null, which
keeps the cover line cleared next time. The export panel seeds its input from
the auth payload during render, gated per user id, so a late resolve never
clobbers a field the user is already typing into, and a finished export
invalidates the cached payload so a remount reads the new value.
2026-07-25 15:00:27 +02:00
Marc-André Bombeck ddc18bbd11 Merge pull request #637 from MBombeck/release/v1.32.34
release/v1.32.34 — one shell and one rhythm for the insights overview
2026-07-25 14:40:16 +02:00
Marc-André Bombeck c623745942 chore(release): v1.32.34 — one shell and one rhythm for the insights overview
Interface housekeeping. The overview sections drop their hand-rolled card
frames for a shared shell with one inner rhythm, failed reads on dense metric
pages get a named row primitive that puts the message in the content tier, and
the last off-scale spacing and text sizes go back onto the scale.

The tag chip and the sub-twelve-pixel text step are primitives now, so neither
can be hand-copied at a slightly different value again.
2026-07-25 14:21:30 +02:00
Marc-André Bombeck 338808eb5f refactor(ui): route the sleep-composition header through the tile header
The stage-composition chart card was the last insights tile with a
hand-rolled title row. It now uses the canonical header primitive; the
subtitle and the window toggles keep the shape they had, since the toggles
have to drop to their own row on a phone.

Also sweeps the last off-scale spacing: the two dense tiles inside a
recommendation move onto the sanctioned inner-tile step, and the not-found
button onto the scale. The banned twenty-pixel step is now absent from the
tree. The chart body is untouched.
2026-07-25 14:12:01 +02:00
Marc-André Bombeck 2decc37f07 refactor(insights): one shell and one rhythm for the overview sections
Seven overview sections had hand-rolled the same card literal, and the copies
had already drifted on the one thing the literal did not pin: the space
between blocks. Three used 12 px, two 8 px, one 16 px, one under the other in
the same column.

`InsightSectionCard` composes `Card`, so the frame comes from the card
contract, and fixes the inner rhythm at 12 px. The daily briefing moves onto
it as well, and the lab-changes list keeps its edge-to-edge rows through the
documented flush variant.

Visible change on the overview column: two bodies open by 4 px, one tightens
by 4 px, and all of them pick up the shadow their card neighbours already
had. Chart bodies are untouched.
2026-07-25 14:09:22 +02:00
Marc-André Bombeck b4f4cb8b55 refactor(ui): promote the compact read-failure row to a primitive
The HealthKit metric sub-pages and the vitals dashboard had each hand-rolled
the same failed-read row, byte for byte. The tall centred error card is the
right shape for a list surface but dominates a dense metric strip, so the row
stays — as a named primitive rather than a third copy.

`QueryErrorRow` carries the shipped shape with one correction: the failure
message renders in the foreground tier. An alert is the content of a failed
surface, not meta about it, and both copies had it muted. The retry glyph
matches its tall sibling. Both converted surfaces pass their existing slot
names through, so their hooks are unchanged.
2026-07-25 14:02:59 +02:00
Marc-André Bombeck 933dfcfdfb refactor(ui): mint the text-2xs token and the neutral TagChip
Three integration cards carried the same hand-copied chip literal in the
card-header accessory slot, each free to drift on the next touch; the size
itself was an arbitrary value repeated at three slightly different spellings
across the tree.

- Mint `--text-2xs` (0.6875rem with a matched line-height) as the one
  sanctioned step below `text-xs`.
- Mint `TagChip` and adopt it on the Fitbit, Google Health, and Nightscout
  cards. It stays distinct from `Badge`, which carries status meaning.
- Sweep the arbitrary micro sizes: the stepper label and the two dose-ledger
  outline badges move onto the token; the muted target caption and the muted
  ledger meta row move up to the `text-xs` muted floor.

No visible change beyond the two muted rows gaining a fraction of a pixel.
2026-07-25 14:00:25 +02:00
Marc-André Bombeck 405bebf1a0 test(e2e): stop the medications refetch spec racing its own listener
Arm the response waiter before navigating, so a response that lands during
the initial load is still observed instead of timing out on an event that
already happened, and poll for the returning refetch rather than sleeping a
fixed span that a loaded machine can outrun.
2026-07-25 12:13:47 +02:00
Marc-André Bombeck d9b8a69b7f Merge pull request #635 from MBombeck/release/v1.32.33
release/v1.32.33 — remove the moodLog integration
2026-07-25 12:13:00 +02:00
Marc-André Bombeck a006f76320 chore(release): v1.32.33 — remove the moodLog integration
Version bump, regenerated OpenAPI spec (version field only; the moodLog routes
were never registered on the public contract), and the release notes.
2026-07-25 11:36:33 +02:00
Marc-André Bombeck d0b45e36b3 feat(integrations): remove the moodLog integration
Mood has been a first-class module here for a long time, with entries,
structured tags, rated factors and notes. The bridge to the external moodLog
service had nothing left to add, and its Settings card was withdrawn in
a56e6fe61 on the promise of a dedicated removal. This delivers it.

Removed: the hourly `moodlog-sync` queue, cron and handler binding; the
inbound webhook and its shared-secret handling; the reverse push and its four
call sites; the pull-sync module; the per-user credential routes and the
instance-wide toggle; the entry on the `/api/integrations/status` envelope,
the `IntegrationKey` union, the cadence map and the display-name table; the
boot-time secret rotation pass; 22 message keys per locale.

Migration 0271 drops the five `users` credential columns and
`app_settings.mood_log_global`, clears the ledger row for the provider, and
deletes the retired pg-boss schedule so no cron keeps enqueuing work no
handler drains.

Mood data is untouched. `mood_entries` is not named anywhere in the migration
and cannot be reached from it: the dropped columns carry no index, constraint
or foreign key, and `mood_entries.user_id` references `users(id)`, not any of
them. Rows the bridge imported keep `source = 'MOODLOG'`, and every read path
- list filter, source badge, exports, backups - still resolves that value.

`src/lib/validations/moodlog.ts` held the native mood-entry schemas alongside
the bridge's own; it is now `validations/mood.ts` and carries only the former.
2026-07-25 11:36:18 +02:00
Marc-André Bombeck 4ed8c8f39b Merge pull request #634 from MBombeck/release/v1.32.31
release/v1.32.31 — the integrations page says what is actually happening
2026-07-25 10:14:50 +02:00
Marc-André Bombeck e657bf928e chore(release): v1.32.31 — the integrations page says what is actually happening 2026-07-25 09:41:58 +02:00
Marc-André Bombeck 10593a11a8 test(integrations): cover the verdict ladder and the coverage guarantee
The load-bearing one is the panel-coverage test: render the panel with an
envelope carrying every integration key and require each configured one to
surface as a card or as a fallback row. Drop the fallback row and it goes red,
which is the whole point — the previous shape of this bug was a provider on
the wire that no screen rendered, and no card-shaped test could have caught it.

Alongside it: the ladder table with the month-silent fixture that resolves to
stalled, the 24-hour and 7-day boundaries asserted against literals rather than
the constants they check, the per-metric stale flag firing only under a healthy
provider, and a structural guard that the verdict-to-pill mapping is defined in
exactly one module.

The card suites migrate with the semantics: a transient failure now expects the
warning pill everywhere, and the Apple Health suite trades its bespoke badge
states for pill assertions. The e2e mocks gain the verdict shape, the
nightscout entry, and an assertion that the card-less row is on the page.
2026-07-25 09:41:53 +02:00
Marc-André Bombeck ce74fd52a2 feat(settings): render the sync verdict and per-metric freshness
Three `pillStateFor` implementations disagreed with each other, so the same
ledger state painted red on one card and amber on the next, on one page. All
three are gone; the client projects the server verdict once.

Red now means "your action fixes this" and nothing else. A transient upstream
failure carries the warning tone: telling a user to reconnect against a 503
sends them clicking at something they cannot repair, and the detail belongs in
the inline error line anyway. Three new pill states carry the rest — no recent
data, sync stopped, waiting for first data.

Per-metric freshness has been computed on every status call since v1.27.30 and
rendered nowhere. Every connected card now opens a disclosure listing each data
type the connection has delivered and how long ago, with a quiet one flagged.
Collapsed by default so a 25-type card keeps its shape, but the collapsed
summary already names the count, or the signal hides behind a click. A type a
provider never delivered simply is not listed.

The panel iterates the envelope and renders a plain status row for every
configured entry no card claims. That is the structural half: a connection can
no longer be running, failing and absent from the page at once.

Apple Health drops its bespoke badge for the shared pill, gains the divider and
body inset every sibling carries, and says something when a week has passed
with nothing arriving. Nightscout reads the envelope instead of its own status
round-trip. The OAuth card uses the shared error primitive, all nine cards
carry the divider testid, and the four original connectors gain the "view your
data" link the newer ones already had.
2026-07-25 09:41:37 +02:00
Marc-André Bombeck de47bfa5ba feat(integrations): resolve sync health on the server
The ledger records what the last attempt did. It has no way to say what is
no longer being attempted: a row frozen at `error_transient` for a month
reads exactly like one retrying every hour, because nothing anywhere read
the age of `lastAttemptAt`.

`sync-verdict.ts` derives the missing reading as a pure read-side
projection: eight verdicts down one ladder, no writer, no cron, nothing that
can desynchronise from the row it reads. `stalled` is the new one, and it
deliberately outranks `failing` — an integration nobody is calling is not
failing, it is not running.

Every entry on `/api/integrations/status` carries the verdict, computed
inline from data the route already holds. That placement is the point: a
card-level verdict misses a card-less provider by construction, which is how
a dead sync stayed invisible. Nightscout joins the envelope so its card can
stop fetching its own status.

The freshness module gains the workout leg (Strava writes no measurements at
all) and a source-parameterised core so the Apple Health route can reuse it.
Its per-metric `stale` flag fires only while the provider itself reads
healthy: that combination is the dead-pipe signature, and when the whole
connection has stopped every metric ages together anyway.

MCP reports the verdict beside `connected`, which only ever meant "not
explicitly disconnected".
2026-07-25 09:41:20 +02:00
Marc-André Bombeck a1a978f5e6 Merge pull request #633 from MBombeck/release/v1.32.30
release/v1.32.30 — height is entered and shown in the unit you use
2026-07-25 08:38:00 +02:00
Marc-André Bombeck 41c2213fe6 chore(release): v1.32.30 — height is entered and shown in the unit you use 2026-07-25 08:21:57 +02:00
Marc-André Bombeck 7a0e2cb5b2 test(profile): cover the height round-trip, the converted bounds, and both entry surfaces
Sweep every feet + inches pair the fields can offer and every whole-inch
height across the canonical range, asserting the value comes back
unchanged and lands inside the server's window. Assert the metric branch
with toBe so a re-derivation of a stored height cannot slip through.

Render both entry surfaces under each preference: the metric case keeps
the single centimetres box with the canonical guardrails, the imperial
case shows the feet + inches pair seeded from the stored value. Extend
the structural guard so a surface that drops the adapter, hardcodes a
centimetre guardrail, or hardcodes a unit label trips a tripwire.
2026-07-25 08:21:51 +02:00
Marc-André Bombeck 1b41126fb3 feat(profile): enter and show height in the selected unit
Height is a profile column, not a MeasurementType, so the display
transform that carries the metric/imperial preference across every
reading surface never reached it. Add the adapter that closes the gap.

With imperial selected both entry surfaces render a feet + inches pair
instead of a centimetres box; with metric selected the field is what it
was. The adapter converts to canonical centimetres at the entry
boundary, so storage, the wire, the Zod bound, and every consumer of
the value stay unchanged.

Imperial entry quantises to whole inches, which map to centimetres
without a remainder, so save-then-reopen returns the typed pair. The
offered feet window rounds inward against the canonical 50-300 window
so a height the fields allow is never refused on save.

The unit select shares the Settings card with the height field, so a
flip re-expresses the in-flight draft through the new branch rather
than stranding an empty field.
2026-07-25 08:21:42 +02:00
Marc-André Bombeck d0d880d4ba docs(changelog): credit the reporter behind the unit-preference fix by name 2026-07-25 08:03:28 +02:00
Marc-André Bombeck 5b32f6ce4c Merge pull request #632 from MBombeck/release/v1.32.29
release/v1.32.29 — a re-scored Fitbit night stops double counting
2026-07-25 06:32:45 +02:00
Marc-André Bombeck fb74b686e3 chore(release): v1.32.29 — a re-scored Fitbit night stops double counting
Stable per-segment sleep keys plus the replace-by-window sweep, a one-shot
repair over the past year, Retry-After handling, and the unchanged-value write
skip. Migration 0270 records which connections the repair has been through.
2026-07-25 06:15:37 +02:00
Marc-André Bombeck a08c759763 test(fitbit): cover the stable sleep key, the sweep, and the one-shot repair
Mirrors the Google sleep suites name for name: the mapper keeps its externalIds
across a re-score that re-classifies and renumbers segments, the sweep stays
bounded to the night's window and never touches the fresh keep-set, a null
window issues no delete at all, and a cleanup failure never fails the sync.

The end-to-end fixture runs one night stored under the old keys through
`syncUserSleep` and pins the ordering the heal depends on — sweep first, then
the rescue re-keying the tombstoned rows in place, no parallel insert — plus a
second pass that writes nothing at all.

Also covers the repair's verdict gates (bounded 365-day window, marker stamped
only on a clean run, watermark untouched, dead token and reauth park returning
without a stamp), the unchanged-value skip including the re-classified segment
it must not swallow, and both Retry-After paths.
2026-07-25 06:15:37 +02:00
Marc-André Bombeck ce41d5bf17 fix(fitbit): key sleep segments on their start instant and sweep re-score leftovers
Fitbit re-scores a night after the fact, and the per-segment externalId carried
a positional index plus the stage label. Both moved on a re-score: renumbered
segments minted fresh keys, the previous scoring's rows stayed live because
nothing swept them, and the night total counted both copies from then on.

Key the segment on the stable session anchor plus its own start instant
(`<logId>:sleep:<segment-start>`), and add the replace-by-window sweep every
other segment-writing provider already has. The two ship together with the
natural-key rescue that has been sitting in the Fitbit upsert unused: the sweep
tombstones the old-keyed rows first, so the rescue re-keys those very rows in
place instead of inserting a parallel copy or losing the insert to
`skipDuplicates` against the natural unique index.

A one-shot repair queue re-reads the bounded sleep history once per connection
so no user has to do anything; it stamps `sleepRepairedAt` (migration 0270) only
on a clean run, never touches the sync watermark, and reports the rows it
removed. Nights older than the 365-day backfill horizon keep their old keys and
whatever duplicates they already hold — they can no longer gain new ones.

Alongside: `fitbitGet` now honours a `Retry-After` inside a 120 s cap by waiting
once and retrying once, and fails fast above it so a long hold cannot starve the
rest of the poll cohort or outlive a job's expiration. The measurement upsert
skips a write whose payload is unchanged, which takes the hourly overlap off the
delta feed for every Fitbit resource.
2026-07-25 06:15:37 +02:00
Marc-André Bombeck a11717e65a Merge pull request #631 from MBombeck/release/v1.32.28
release/v1.32.28 — Oura catches up after a gap, and the last four providers get manual sync
2026-07-25 05:03:46 +02:00
Marc-André Bombeck 40a746b2b7 chore(release): v1.32.28 — Oura catches up after a gap; manual sync for the last four providers 2026-07-25 04:43:18 +02:00
Marc-André Bombeck 0bcbb88db1 feat(settings): put a sync-now button on the four cards that lacked one
A route with no caller is an API detail, not a feature. The Oura, Polar,
Strava and Nightscout cards get the same action WHOOP already had, in the
same row as Test connection, rendered only once connected: post the sync,
show the count or the failure, invalidate the reads that depend on
measurements. No full-history dialog, since these providers have no such
arm. Keys added across the six locales.
2026-07-25 04:43:12 +02:00
Marc-André Bombeck a9f1629f82 feat(integrations): add manual sync for Oura, Polar, Strava and Nightscout
These four were the only connected providers with no way to ask for a
sync; they ran on their scheduled pass and nothing else. Each gets a POST
sync route on the hardened Fitbit and Google Health template: session auth,
a 5-per-minute per-user bucket, and a fixed 502 on failure. No body and no
full-sync flag, because none of the four entry points has a full-history
arm to call. Polar goes through the shared legs entry point so both legs
run with the attribution rules built for them.

The Nightscout error arm is the one that matters: a base URL carries the
API token as a query parameter, so the caught message can hold the user's
secret and never reaches the response body. The classified failure is
already on the ledger, recorded with the provider's own redaction.

Connecting also starts the first sync now. The poll handler has accepted a
single-user payload since it was written and never had a producer, so a
fresh connection sat empty for up to an hour waiting for the schedule. The
Oura and Polar callbacks and the Nightscout connect route enqueue one
targeted job each, best effort, with the hourly tick still the safety net.
Strava needs none: its callback already enqueues the history backfill,
which covers the recent window.

The four poll-queue names move into their own module so a request-path
caller can name a queue without dragging the whole worker handler graph
into the request bundle, following the backfill-queue precedent. Creation,
scheduling and handler binding stay in the registrar, and the dead-queue
guard follows the declarations.
2026-07-25 04:43:06 +02:00
Marc-André Bombeck 9bbbeb805c fix(oura): derive the sync window from the newest imported reading
The lookback was pinned at seven days, so a sync that stayed broken for
longer than a week came back with a window too narrow to reach the gap.
The quieter case did the same damage with every signal green: a ring left
unsynced holds its nights in Oura's cloud until the app is opened, every
hourly tick succeeds with empty arrays, and when the days finally arrive
they sit outside the window.

Anchor the window on the newest live Oura measurement instead, clamped
between the existing seven-day floor and a new thirty-day ceiling, plus a
day of overlap. Both failure modes leave the same fingerprint, so one
anchor closes both, and the watermark cannot drift out of step with the
data because it is the data. Tombstones are excluded: the sleep sweep
soft-deletes rows a revised hypnogram orphaned, and one of those must not
pass for a live reading.

The ceiling is thirty rather than the ninety days Oura's guidance would
allow, because the whole batch still rides one transaction. With data
flowing normally the clamp floors at seven and the tick is unchanged.
A connection with no rows at all takes the full month.
2026-07-25 04:42:52 +02:00
Marc-André Bombeck 8af5c6a0eb Merge pull request #630 from MBombeck/release/v1.32.27
release/v1.32.27 — target and threshold panels follow the unit preference
2026-07-25 03:43:29 +02:00
Marc-André Bombeck 7a4e819679 chore(release): v1.32.27 — target and threshold panels follow the unit preference 2026-07-25 03:24:24 +02:00
Marc-André Bombeck c86de7d317 test(targets): pin the target unit round-trip and extend the display guard
Cover the adapter end to end: an imperial seed, a guardrail projected
into the display unit, a save inverted back to canonical, and a reopen
that shows the number the user typed, swept across the whole editable
window at the input's own step. Temperature carries the affine case,
where a difference must take the factor without the offset.

Assert the metric path is the exact identity rather than merely close,
so a stored value cannot drift by a rounding pass.

Extend the structural guard to sweep the three target and threshold
surfaces, and add two tripwires: each must call the preference hook and
convert through the shared adapter. Reintroducing a hardcoded unit or
swapping the hook for a literal now fails the guard.
2026-07-25 03:24:19 +02:00
Marc-André Bombeck bf73755899 fix(targets): follow the unit preference in target and threshold panels
Target ranges stayed canonical while every reading surface moved to the
metric/imperial preference in the previous release, so an imperial
account saw a weight chart in pounds above a target reference in
kilograms. The panel is a coupled display and edit unit: converting only
its display would have corrupted the edit seed and the save round-trip,
which is why it was held back.

Add one adapter that owns every direction of the conversion and route
all three surfaces through it. The reference panel converts the
canonical payload once and hands the converted pair to the range bar,
the status pill, and the editor seed. The editor seeds from the stored
value converted to the display unit, validates against guardrails
projected into that same unit, and inverts the typed number back to
canonical before the PUT.

Guardrails round inward, so the 30 kg floor projects to 66.2 lb rather
than 66.1 lb, which would have inverted below the floor the server
enforces. Absolute values carry the affine offset, differences take the
factor alone. Input step comes from the transform's own precision.

Storage is untouched: thresholds stay canonical SI, and for a metric
account the adapter is the exact identity in both directions, so a
stored value is unchanged bit for bit.
2026-07-25 03:24:10 +02:00
Marc-André Bombeck cfd5cf0e0c Merge pull request #629 from MBombeck/release/v1.32.26
release/v1.32.26 — apply the metric/imperial preference across every reading surface
2026-07-25 00:48:54 +02:00
Marc-André Bombeck 2e5841c337 docs(changelog): date v1.32.26 and note the target-panel unit gap 2026-07-25 00:37:28 +02:00
Marc-André Bombeck a9b2ab2d2b chore(release): v1.32.26 — apply the metric/imperial preference across every reading surface
Surface a self-hoster's report that the unit preference was stored but read by
almost no display. Weight, body composition, waist, and temperature now follow
metric or imperial at entry and everywhere they render, storage stays canonical
SI, and the MCP logging tool converts a known unit hint or refuses an unknown
one. OpenAPI diff is version-only; the wire stays canonical.
2026-07-25 00:35:07 +02:00
Marc-André Bombeck 6cf56ce456 test: mock the unit-display hook in the crosstab, vitals, and MCP suites
The crosstab, vitals, and cycle-summary renders are static server renders with
no QueryClient; the new preferred-unit hook reads through the auth query, so
mock it to a real metric-preference implementation (identity values, canonical
symbols) that matches the render those suites assert. Register the new unit
resolver on the MCP write-tools module mock so the preview path resolves.
2026-07-25 00:34:51 +02:00
Marc-André Bombeck ddc598afcb test(measurements): cover the unit transform, MCP unit hardening, and a display guard
Add affine-correctness tests for the transform registry (°C→°F absolute vs a
delta that must stay offset-free, lb/in factors, inverse round-trips), a
resolver test for the inbound unit aliases, a preferred-unit entry-form label
test, and MCP cases for converting a lb hint to canonical kg and refusing an
unknown unit. Add the structural guard that trips if any dashboard or insight
surface reintroduces a hardcoded transformed-type unit or value scale. Ship the
diagnose/convert script pair for legacy non-canonical measurement rows.
2026-07-25 00:21:36 +02:00
Marc-André Bombeck 13b88e47f5 fix(insights): convert weight and temperature in the mood/cycle crosstabs and the plateau note
Route the weight and temperature displays in the mood-factor and cycle-phase
crosstabs and the GLP-1 plateau note through the unit transform: absolute phase
averages take the affine conversion, deltas stay factor-only, and the unit
symbol follows the preference. Thread a unit token into the plateau evidence
copy across the six locales so the sentence reads in the user's unit.
2026-07-25 00:16:41 +02:00
Marc-André Bombeck e1ca523e21 fix(insights): route every weight-class and temperature surface through the unit transform
Wire the display transform into the chart scaffold, the chart itself (with an
affine offset for the temperature conversion), the dashboard trend tiles and
weight chart, the vitals grid, the coach read strip, and the device-score
tile. Each surface resolves its unit and scale from the user's preference at a
single choke point and stops hardcoding the canonical unit, so an imperial user
sees a consistent value, axis, band, tooltip, and range hint while a metric
user's render stays byte-identical. Retire the local celsius display shim now
that the transform owns the symbol.
2026-07-25 00:10:53 +02:00
Marc-André Bombeck dd7213db5c fix(measurements): honor the unit preference at entry and in the record list
Add the affine display-transform registry (kg↔lb, cm↔in, °C↔°F with the
temperature offset carried as an absolute-only shift), a single client hook
that reads the preference, and a shared inbound unit-alias resolver. Wire the
manual entry form to label, prefill, and convert at the boundary so an imperial
reading persists in canonical SI, and route the record list value cells and
edit dialog through the same choke point. Close the one write path that could
persist a non-canonical unit: the MCP log_measurement tool now resolves a unit
hint to canonical or refuses it, reusing the resolver the CSV importer already
relied on.
2026-07-24 23:58:45 +02:00
Marc-André Bombeck 14dcab786e Merge pull request #628 from MBombeck/release/v1.32.25
release/v1.32.25 — cap external medication-mirror growth and surface provenance
2026-07-24 23:32:51 +02:00
Marc-André Bombeck bb79d28911 chore(release): v1.32.25 — surface mirrored-medication provenance and cap external mirror growth 2026-07-24 23:18:26 +02:00
Marc-André Bombeck 40aae4b66d chore(scripts): apply prettier to the phantom-med cleanup script
Formatting only; the format:check gate flagged the file. No behaviour change.
2026-07-24 23:18:26 +02:00
Marc-André Bombeck 1007ca3d1c test(medications): cover the mirror growth guard and provenance echo
A 31st new mirror create at the cap returns 422 with the limit_exceeded
errorCode; an idempotent re-post of an existing mirror at the cap still returns
200; a native create at the cap is unaffected and never runs the count. The
list read exposes externalSource as APPLE_HEALTH for a mirror and null for a
native row.
2026-07-24 23:18:26 +02:00
Marc-André Bombeck dbe3c3dc26 fix(medications): cap externally mirrored rows and surface their provenance
A client that mints a fresh externalId for the same source concept on every
sync sweep never matches the (userId, externalSource, externalId) idempotency
triple, so each sweep would otherwise create a new mirror row without bound.
Add a growth guard on POST /api/medications: once an account already holds 30
medications mirrored from an external source, a genuinely new mirror create is
refused with 422 and the errorCode medications.mirror.limit_exceeded. The guard
sits after the idempotency lookup, so an idempotent re-post of a medication the
account already mirrors still returns 200, and native creates never run the
count.

Echo externalSource on the medications list read so the web UI and an operator
can tell a mirrored row from a native one, and document both in the OpenAPI
contract.
2026-07-24 23:18:14 +02:00
Marc-André Bombeck 6c8e921faa chore(scripts): add read-only diagnose + guarded cleanup for phantom Apple-Health mirror meds
The diagnose script reports the provenance, external-id values, and intake/schedule
counts of medications by name. The cleanup script deletes only name-matched
Apple-Health mirror rows with zero intakes and zero schedules, dry-run by default,
replicating the delete route (category cleanup, token revoke, audit, cache bust).
2026-07-24 23:02:03 +02:00
Marc-André Bombeck 816f517d1b Merge pull request #626 from MBombeck/release/v1.32.24
release/v1.32.24 — isolate Polar collections and log envelope drift
2026-07-24 22:10:37 +02:00
Marc-André Bombeck 4ac39a7052 chore(release): v1.32.24 — isolate Polar collections and log envelope drift
Per-collection isolation for the Polar vitals sync: one unavailable data type
no longer blanks the rest, a failing collection is named on the integration
status, and an unexpected response shape is logged instead of reading as no
data. Bump version, regenerate the OpenAPI version string, add the changelog
entry.
2026-07-24 21:26:35 +02:00
Marc-André Bombeck 1d31c51d01 fix(polar): log envelope-shape drift instead of silently reading it as no data
The collection fetchers degrade an unexpected 200 body to an empty list, so a
Polar-side envelope rename would read as "the user has no data" and stamp a
clean sync while importing nothing. Emit a fixed-shape polar.envelope.drift
breadcrumb (verb, HTTP status, top-level key names only, never values) at each
silent-empty site in the collection fetcher and the exercises fetcher, keeping
the log-and-degrade behavior so a live account stays alive while the gap becomes
visible in the event stream.

Truth up the client docstrings against the verified AccessLink contract: each
vitals list is a bare array or a named wrapper over a server-fixed 28-day window
with no pagination, and exercises is a bare array over the last 30 days of
uploads. Every hourly tick re-reads the full window, so a late-synced device or
a short outage self-heals with no cursor or watermark; history beyond the window
is an upstream cap. No request parameters added.
2026-07-24 21:19:58 +02:00
Marc-André Bombeck 4574155ce6 fix(polar): isolate each vitals collection so one dead read no longer blanks the rest
Restructure the Polar vitals sync from a single Promise.all over the five
collections into a Promise.allSettled over a per-collection spec, each closure
fetching and mapping so a throwing mapper is contained exactly like a failing
fetch. The healthy collections import; a failing one is recorded once as a
partial failure that names it, without stamping success and without rethrowing,
so a single unavailable data type — SpO2 on a watch without the sensor, a
higher-tier cardio-load read an account lacks — stops blanking recharge, sleep,
and activity on every tick.

A 401 is now a per-collection failure classified reauth_required, not a
whole-leg throw; the write-path escape still surfaces unmarked for the poll
boundary to record once. Mirrors the shipped Oura isolation, minus the
token-refresh leg Polar has no path for.
2026-07-24 21:19:46 +02:00
1238 changed files with 108506 additions and 25340 deletions

No files matched your search

+3
View File
@@ -247,3 +247,6 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# -----------------------------------------------------------------------------
# Opt-in for a plain-HTTP IP_GEO_LOOKUP_URL provider (e.g. free ip-api.com); off by default — looked-up IPs then travel unencrypted over your server's egress.
# IP_GEO_ALLOW_INSECURE="true"
#
# Directory the offline GeoLite2 databases are read from; default /opt/geolite2 (the path the image bakes its own copy into). Point it at a directory you mount yourself to bring your own MMDBs — see docs/self-hosting/geolite2.md.
# GEOLITE2_DIR="/var/lib/geolite2"
+18
View File
@@ -293,6 +293,24 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# off the resolver uses only the offline GeoLite2 MMDB tier, if present.
# IP_GEO_LOOKUP_DISABLED="1"
# -----------------------------------------------------------------------------
# Offline GeoLite2 databases -- optional
# -----------------------------------------------------------------------------
# Where the runtime looks for GeoLite2-City.mmdb and GeoLite2-ASN.mmdb.
# Unset it reads /opt/geolite2, the path the published image bakes its own
# copy into -- but only if the image build had a MaxMind licence key, so on
# a stock `:latest` the directory is usually empty and every login IP goes
# to the online provider instead.
#
# To resolve locations on your own host: create a free MaxMind account,
# pull both databases with `geoipupdate`, mount the directory into the
# container read-only (the commented `volumes:` block in docker-compose.yml
# shows the line), and point this at the container path. Pair it with
# IP_GEO_LOOKUP_DISABLED="1" above for zero IP egress. Refreshing the files
# on the host is enough -- the reader picks them up on the next container
# restart. See docs/self-hosting/geolite2.md.
# GEOLITE2_DIR="/var/lib/geolite2"
# -----------------------------------------------------------------------------
# Environmental-context module (Open-Meteo) -- optional
# -----------------------------------------------------------------------------
@@ -39,7 +39,6 @@ body:
- Notifications
- AI Insights
- Withings integration
- moodLog integration
- Self-hosting / Docker
- Other
validations:
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
COACH_EVAL_API_KEY: ${{ secrets.COACH_EVAL_API_KEY }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
if: github.actor == 'dependabot[bot]'
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
+4 -4
View File
@@ -46,7 +46,7 @@ jobs:
contents: read
steps:
- name: Checkout full history
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
@@ -129,7 +129,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
@@ -150,7 +150,7 @@ jobs:
# Image is built but not pushed in that case.
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -350,7 +350,7 @@ jobs:
echo "IMAGE_REF=${REGISTRY}/${IMAGE_NAME,,}" >> "$GITHUB_ENV"
- name: Log in to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
+5 -2
View File
@@ -45,7 +45,7 @@ jobs:
NODE_ENV: production
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
@@ -74,7 +74,10 @@ jobs:
# the versioned service-worker stamp AND the static i18n catalog
# assets (public/i18n/<locale>.js) the client boots from — without
# them every UI interaction waits on missing catalogs.
- run: pnpm build
# The repository-wide Next type worker exceeds V8's ~4 GiB default
# old-space ceiling. Keep the larger heap scoped to this production
# build only; tests and the runtime retain their normal limits.
- run: NODE_OPTIONS=--max-old-space-size=8192 pnpm build
# Client-bundle budget gate — rides this job because it is the one
# place CI already has a production build on disk. Catches the
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
+11 -2
View File
@@ -12,9 +12,18 @@ permissions:
jobs:
integration:
runs-on: ubuntu-latest
timeout-minutes: 15
# This guard exists to kill a hung testcontainer or a deadlocked query, not
# to hold the suite to a performance budget. At 15 it had stopped telling
# those apart: the suite itself takes around 13 minutes of job time (128
# files against a real Postgres), so two consecutive runs of the SAME
# commit range came in at 12m50s and then over 15m, and the second was
# killed mid-run with every test to that point green. A cap that a healthy
# run clears only on a fast runner reports runner weather, not health.
# 25 leaves real headroom and still catches a hang inside one CI cycle.
# If the suite ever approaches this number, split it rather than raise it.
timeout-minutes: 25
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Scan src/ for TODO/FIXME/XXX/HACK markers
run: |
+1 -1
View File
@@ -71,7 +71,7 @@ jobs:
steps:
- name: Log in to GHCR
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ghcr.io
username: ${{ github.actor }}
+4 -4
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
@@ -66,7 +66,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
@@ -91,7 +91,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
@@ -106,7 +106,7 @@ jobs:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Build Docker image
run: docker build -t healthlog:scan .
+1 -1
View File
@@ -62,7 +62,7 @@ next-env.d.ts
# COPY target always exists.
/assets/geolite2/*.mmdb
# AI session artefacts (kept locally, not committed)
# Local workspace files, never committed
/docs/superpowers/
.claude/
.cursor/
-246
View File
@@ -1,246 +0,0 @@
# AGENTS.md
Instructions for AI coding agents (OpenAI Codex, Claude Code, Cursor, etc.) working on this repository.
## Project
**HealthLog** — a personal health-tracking web app (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, moodLog.app sync, Dracula-themed UI, mobile-first PWA design.
**Status**: v1.4.0 (released) → v1.4.1 in progress. v1.4.0 shipped
the UI guidelines + Skeleton/EmptyState primitives, medical-citations
consolidation, the per-route `/settings/[section]` split, the
status-first admin grid + aggregator endpoint, five new "Test
connection" buttons, AI insights with inline charts via allowlisted
metric tokens, off-host encrypted S3 backup, encryption-key
versioning + rotation CLI, optional `HEALTHLOG_PROCESS_TYPE` worker /
web split, and short-lived 24h access tokens with refresh-token
rotation for native API clients. v1.4.1 follows up with per-section
admin component extraction (the inner monolith into one file per
panel), a Postgres testcontainers integration test suite, and a
Playwright + axe-core E2E foundation. See GitHub Releases +
CHANGELOG.md for the full feature timeline.
## Tech Stack
| Layer | Technology | Version | Notes |
| --------------- | -------------------- | ------- | ----------------------------------------------------------------------- |
| Framework | Next.js (App Router) | 16 | TypeScript strict, RSC default, `"use client"` only for interactivity |
| ORM | Prisma | 7.8 | Uses `PrismaPg` adapter, **not** `url` in schema — see gotchas below |
| Database | PostgreSQL | 16 | Docker Compose service, port 5432, user `healthlog` |
| UI | shadcn/ui (new-york) | latest | Components in `src/components/ui/` |
| Theme | Dracula | — | CSS variables in `globals.css`, dark mode default, `--dracula-*` tokens |
| CSS | Tailwind | 4 | CSS-first config (`@import "tailwindcss"` syntax) |
| Data fetching | TanStack Query | 5 | Provider in `src/components/providers.tsx` |
| Validation | Zod | v4 | Import as `zod/v4` (not `zod`) |
| Testing | Vitest | latest | Config in `vitest.config.mts` |
| Package manager | pnpm | latest | **Not** npm or yarn |
| Node | 20.x | via nvm | |
| Job queue | pg-boss | 12 | Named import `{ PgBoss }`, see gotchas |
| Auth | SimpleWebAuthn | 13 | Passkeys primary, password fallback |
| i18n | Custom context-based | — | `useTranslations()` hook, `messages/de.json` + `messages/en.json` |
| PDF | jsPDF + autotable | latest | Client-side doctor report generation |
## Commands
```bash
# Development
pnpm dev # Start dev server (http://localhost:3000)
pnpm build # Production build
pnpm lint # ESLint
pnpm format # Prettier format
pnpm format:check # Prettier check
pnpm typecheck # TypeScript strict check
pnpm openapi:generate # Regenerate docs/api/openapi.yaml from Zod schemas
pnpm openapi:check # Verify the spec is in sync (CI gate)
# Git hooks (one-time per clone)
bash scripts/install-hooks.sh # Activates .githooks/, including the OpenAPI pre-commit check
# Tests
pnpm test # Run all tests (vitest)
pnpm test:watch # Watch mode
# Database
pnpm db:generate # Generate Prisma client
pnpm db:migrate # Create & apply migration (dev)
pnpm db:migrate:deploy # Apply migrations (production)
pnpm db:push # Push schema without migration (prototyping)
pnpm db:studio # Prisma Studio GUI
# Docker
docker compose up -d # Start app + postgres
docker compose logs -f app # Tail app logs
# Verification (run before completing tasks)
pnpm typecheck && pnpm lint && pnpm test && pnpm format:check && pnpm build
```
## File Layout
```
src/
├── app/ # Pages and API routes (App Router)
│ ├── layout.tsx # Root layout (viewport-fit: cover for PWA)
│ ├── page.tsx # Dashboard (/) with quick entry dropdown
│ ├── globals.css # Dracula theme CSS variables
│ ├── admin/page.tsx # Admin shell — 77 LOC, mounts the per-section components from src/components/admin/
│ ├── auth/login/page.tsx # Login
│ ├── auth/register/page.tsx # Registration
│ ├── achievements/page.tsx # Gamification achievements
│ ├── bugreport/page.tsx # Bug report → GitHub issue
│ ├── insights/page.tsx # Health insights + correlations + AI (7 sections with sticky nav)
│ ├── measurements/page.tsx # Measurements CRUD table
│ ├── medications/page.tsx # Medications management
│ ├── notifications/page.tsx # Notification preferences matrix
│ ├── onboarding/page.tsx # 4-step guided onboarding
│ ├── settings/page.tsx # 308-redirects /settings → /settings/account
│ ├── settings/[section]/page.tsx # Per-section route shell (account, integrations, notifications, dashboard, ai, api, advanced, about)
│ ├── mood/page.tsx # Mood tracking
│ ├── targets/page.tsx # Target values dashboard
│ └── api/ # 100+ API route files (admin, auth, measurements, medications, mood, insights, integrations, ingest, dashboard, feedback, tokens, notifications, monitoring, …)
│ ├── mood-entries/ # Mood CRUD
│ ├── import/ # JSON data import
│ ├── doctor-report/ # Doctor report data aggregation
│ ├── gamification/ # Achievements (persistent)
│ ├── integrations/moodlog/ # moodLog.app webhook + sync + status
│ └── ...
├── components/
│ ├── ui/ # shadcn/ui primitives
│ ├── layout/ # Shell: sidebar-nav, topbar, mobile-nav, bottom-nav
│ ├── medications/ # Medication form, card, timeline
│ ├── measurements/ # Measurement form, list
│ ├── mood/ # Mood form, mood list
│ ├── charts/ # Recharts wrappers, compliance charts
│ ├── insights/ # AI insights cards (status, advisor)
│ ├── gamification/ # Achievement cards, progress
│ ├── settings/ # Per-route Settings section components (one per /settings/[section])
│ ├── admin/ # Per-route Admin section components (system-status, integrations, monitoring, reminders, users, audit, danger-zone, feedback)
│ └── monitoring/ # Umami, GlitchTip bootstrap
├── lib/
│ ├── db.ts # Prisma client singleton
│ ├── crypto.ts # AES-256-GCM encryption
│ ├── gravatar.ts # Gravatar URL from email (SHA-256)
│ ├── api-response.ts # { data, error, meta } envelope helpers
│ ├── rate-limit.ts # In-memory sliding window rate limiter
│ ├── export.ts # CSV/JSON export formatting (incl. mood)
│ ├── doctor-report-pdf.ts # Client-side PDF generation (jsPDF)
│ ├── auth/session.ts # getSession() → { session, user } | null
│ ├── analytics/ # Trend calculations, compliance, correlations
│ ├── i18n/ # I18n context + config
│ ├── notifications/ # Dispatcher, types, senders (telegram, ntfy, web-push)
│ ├── jobs/ # pg-boss worker (reminders, insights, backups)
│ ├── validations/ # Zod schemas (shared between API + client)
│ ├── ai/ # Multi-provider client (OpenAI, Anthropic, local OpenAI-compat)
│ ├── feedback/ # In-app feedback + optional GitHub escalation
│ ├── gamification/ # Achievement definitions + progress
│ ├── insights/ # Insight pipeline + prompts
│ ├── logging/ # Wide Events: builder, context, sampler, transports, background
│ ├── moodlog/ # moodLog.app webhook + sync
│ ├── monitoring/ # Umami / GlitchTip server-side hooks
│ └── withings/ # Withings OAuth client + sync service
├── hooks/
│ └── use-auth.ts # useAuth(), useLogout() hooks
├── generated/prisma/client/ # Generated Prisma client (DO NOT EDIT)
messages/
├── de.json # German translations (primary UI language)
└── en.json # English translations
prisma/
├── schema.prisma # Database schema (26 models)
└── migrations/ # Migration files (0001–0025; latest: refresh_tokens + user_locale_drift_fix)
prisma.config.ts # Prisma config (DB URL lives here, NOT in schema)
public/
├── sw.js # Service worker (Web Push + offline caching)
├── manifest.json # PWA manifest
└── ... # Static assets
docs.healthlog.dev # External documentation site
```
## Key Conventions
- **Default locale is English**. All code-level strings in English. UI text via i18n (`t()` calls) with German + English translations.
- **i18n**: Use `useTranslations()` hook → `t("section.key")`. Supports parameter interpolation: `t("key", { count: 5 })`. Messages in `messages/de.json` + `messages/en.json`.
- **API response format**: Always `{ data, error, meta }` via `apiSuccess(data)` / `apiError(message, status)` from `src/lib/api-response.ts`.
- **Auth check pattern**: `const session = await getSession(); if (!session) return apiError("Not authenticated", 401);`
- **Timezone**: `Europe/Berlin` for display, UTC in database.
- **Encryption**: Sensitive data (Withings tokens, API keys, VAPID private keys) encrypted with AES-256-GCM via `src/lib/crypto.ts`.
- **Dracula colors**: Use CSS variables `var(--dracula-purple)`, `var(--dracula-cyan)`, `var(--dracula-green)`, `var(--dracula-orange)`, `var(--dracula-pink)`, `var(--dracula-red)`, `var(--dracula-yellow)`, `var(--dracula-fg)`, `var(--dracula-comment)` for chart/graph elements.
- **Proxy route protection**: `src/proxy.ts` checks `healthlog_session` cookie on all non-public paths. Unauthenticated page requests → redirect `/auth/login`. API routes keep their own `getSession()` auth. Public paths defined in `PUBLIC_PATHS` array.
- **PWA safe area**: Root layout uses `viewportFit: "cover"`. Bottom-nav has `pb-[env(safe-area-inset-bottom)]`. Auth-shell uses `pb-[calc(5rem+env(safe-area-inset-bottom,0px))]`.
- **PDF generation**: Client-side via `src/lib/doctor-report-pdf.ts` using jsPDF. Data fetched from `POST /api/doctor-report`. European medical format with German terminology.
- **Data backup**: pg-boss weekly job (`data-backup` queue, Sundays 03:00), stores compressed JSON in `DataBackup` model.
- **Achievements**: Persistent in `UserAchievement` table. API at `/api/gamification/achievements` computes current state, compares with DB, persists new unlocks. `unlockedAt` timestamp is stable once set.
## Critical Gotchas
These are hard-won lessons. Ignoring them will cause errors:
### Prisma 7
- **No `url` in schema.prisma** — The database URL is configured in `prisma.config.ts`, not in the `datasource` block.
- **Import path**: `import { ... } from "@/generated/prisma/client"` (with `/client` suffix, not `@/generated/prisma`).
- **Adapter required**: PrismaClient needs `PrismaPg` adapter from `@prisma/adapter-pg`. See `src/lib/db.ts`.
- **No `earlyAccess`** in defineConfig — causes TypeScript errors.
### Next.js 16
- **Proxy, not middleware**: Next.js 16 renamed middleware to proxy. The file is `src/proxy.ts` (not `middleware.ts`). Having both causes a build error: "Both middleware file and proxy file detected".
### Libraries
- **SimpleWebAuthn v13**: No `@simplewebauthn/server/script/deps` — define Transport type inline.
- **zxcvbn-typescript**: Default export only. `zxcvbnAsync` and `zxcvbnOptions` do not exist as named exports.
- **pg-boss v12**: `{ PgBoss }` named import (not default), use `localConcurrency` (not `teamSize`), handler receives `Job<T>[]` array.
- **Zod v4**: Import from `"zod/v4"`, not `"zod"`.
- **jsPDF**: Client-side only. Import dynamically in browser context. Used with `jspdf-autotable` plugin.
### Settings & Admin (per-route layout, v1.4)
- **Settings**: 8 routes under `/settings/[section]` — `account`, `integrations`, `notifications`, `dashboard`, `ai`, `api`, `advanced`, `about`. The legacy `/settings` 308-redirects to `/settings/account`. Sidebar deep-links and the `<a href="/settings#anchor">` patterns from 1.3 still resolve via the redirect.
- **Admin**: `src/app/admin/page.tsx` is now a 77-LOC shell that mounts the per-section components in `src/components/admin/`. Status-card grid lives in `status-card-grid.tsx`. The aggregator endpoint `/api/admin/status-overview` returns the six-card summaries in one batched query.
- ESLint `react-hooks/set-state-in-effect` is **strict** now (was non-blocking when the settings monolith carried inline-effect state-setters). Use lazy `useState(() => …)` for localStorage reads, TanStack Query for data fetches.
### Insights Page
- Large file (~1750 lines), 7 content sections with AI summaries.
- Sticky horizontal chip navigation (`InsightsSectionNav` component) with IntersectionObserver for active state tracking.
- Section IDs: `general`, `bp`, `weight`, `pulse`, `mood`, `meds`, `bmi`.
- Each section has `scroll-mt-28` for header offset on scroll.
### Sidebar
- Collapsible (icons-only mode, `w-16`). State persisted in localStorage (`healthlog-sidebar-collapsed`).
- User section at bottom: Avatar (Gravatar if email, initials fallback) + username + three-dot MoreVertical menu (right side).
- Three-dot menu: Admin link, Notifications, Theme picker, Logout.
- ESLint enforces `react-hooks/set-state-in-effect` — use lazy `useState(() => ...)` for localStorage reads, NOT `useEffect` + `setState`.
### Gravatar
- `src/lib/gravatar.ts` generates Gravatar URL server-side (SHA-256 hash of email, `?d=404` fallback).
- Returned in `/api/auth/me` response as `gravatarUrl` field. Used by `AvatarImage` component with `AvatarFallback` for initials.
### Notification System
- **Channels**: `telegram`, `ntfy`, `web_push` — stored as plain `String` on `NotificationChannel.type` (no Prisma enum; the canonical list lives in `src/lib/notifications/types.ts`).
- **Event types**: `medication_reminder`, `measurement_anomaly`, `compliance_low`, `withings_sync_failed`, `system_alert` — also `String` on `NotificationPreference.eventType`. Source of truth is the discriminated union in `src/lib/notifications/types.ts`.
- **Opt-out model**: Preferences default to ON (enabled) when no `NotificationPreference` row exists.
- **Dispatcher**: `src/lib/notifications/dispatcher.ts` checks channel enabled + preference per event type.
- **Telegram**: Inline buttons (Take/Skip/Snooze 1h/Snooze 3h), pre-end window reminders (30 min before), message auto-cleanup after 24h.
### Onboarding
- 4-step guided flow: Profile → Medications → Notifications teaser → Target values.
- Optional steps 2-4 have "Skip" buttons.
- Progress indicator bar at top.
- Located at `/onboarding`, linked from proxy as public path.
## Database Models (Prisma)
26 models: `User`, `Passkey`, `Session`, `AuthChallenge`, `Measurement`, `Medication`, `MedicationSchedule`, `MedicationIntakeEvent`, `ReminderPhaseConfig`, `TelegramReminderMessage`, `TelegramScheduledDeletion`, `ApiToken`, `RefreshToken`, `WithingsConnection`, `MoodEntry`, `AppSettings`, `Feedback`, `AuditLog`, `NotificationChannel`, `NotificationPreference`, `PushSubscription`, `DataBackup`, `UserAchievement`, `RateLimit`, `Device`, `IdempotencyKey`. (`RefreshToken` added in v1.4.0 alongside the native-client 24h access-token / refresh-token rotation flow.)
## When Making Changes
1. **Read before modifying** — understand the existing code patterns.
2. **Run verification** after changes: `pnpm typecheck && pnpm lint && pnpm test && pnpm format:check && pnpm build`
3. **German UI text** — all user-facing strings go through `t("key")` with translations in both `messages/de.json` and `messages/en.json`.
4. **Don't over-engineer** — keep changes focused. No speculative abstractions.
5. **Update `docs/STATUS.md`** when completing tasks.
+945
View File
@@ -2,6 +2,951 @@
## [Unreleased]
## [1.34.2] — 2026-07-30
Point release. One workout that arrived more than once, and several surfaces
that were reading the same record differently from one another.
### Fixed
- Badminton recorded through Google Health keeps its sport instead of arriving
as "Other". Reported by @tarantila.
- A workout the same source sent twice is now counted once everywhere. The
workout list already collapsed the repeat, and the Coach narration, the
per-sport comparison and the workout deep link did not, so one session could
be folded two or three times into the averages a person is measured against.
The three that missed it were fetching the workout without its duration, and
the identity check needed that field.
- Insights prose renders at full contrast, and the two assessment cards share
one internal rhythm again after one of them drifted away from it.
- The dashboard headline survives for anyone who has switched the score lead
off.
- The service worker's fallback cache version follows the release again. It had
slipped a version behind, which only shows up in a browser that cannot load
the version file, and that browser then kept serving the previous release's
cached pages.
### Credit
Reported by @tarantila, directly rather than on the tracker: the badminton
mapping, following on from the workout-type loss in v1.34.0.
## [1.34.1] — 2026-07-29
Security and reliability point release.
### Added
- Google Health imports now show bounded progress and a clear final result.
- Workouts keep their provider details and can be enriched with available WHOOP
data without inventing pulse curves.
- Medication reminders retain overdue occurrences until their actionable window
has ended and identify each occurrence uniquely.
- Intake imports report completed writes and already-recorded entries clearly.
### Changed
- Dashboard scores, follow-up suggestions and hero metrics use the latest
authoritative readings.
- Public pages, documents and downloads now behave consistently on desktop and
mobile, with tighter spacing and clearer empty states.
- The administration and settings areas use a more consistent navigation and
grouping structure.
### Fixed
- Prevented duplicate medication intakes and reminders for the same occurrence.
- Fixed stale or future readings replacing newer measurements.
- Fixed cache isolation for authenticated PWA pages.
- Fixed passkey step-up, password-reset session revocation and refresh replay
edge cases.
- Restricted private-network integration requests to approved origins and final
sockets.
### Security
- Hardened MCP read permissions, refresh handling, passkey verification and
server-side request validation.
- Apple ECG data remains encrypted and source-labelled. WHOOP ECG is not
inferred from pulse data.
## [1.34.0] — 2026-07-28
Two things this release keeps saying to itself. A job, a sync, or a write
that fails can no longer report success by falling off the end of a
function. And a number the app grades you against is either yours or it
says plainly that it is not.
### A failure now looks like a failure
Every background job in this codebase, all 104 of them across five queues,
used to signal success by returning nothing. A handler that caught an
error, logged a warning, and returned was indistinguishable from one that
finished its work, so a queue could fail quietly for as long as it liked.
Every handler now has to construct and return the outcome of what it did,
and the type system enforces it: a handler that falls off the end no
longer compiles. Retry behaviour was reviewed queue by queue rather than
flipped in bulk, because a rethrow changes what runs, not only what shows
up on a screen.
The same fix reached the surfaces you actually watch. Six integration
cards and the lab import dialog used to key their tone off whether the
network call itself succeeded, so a sync that imported nothing because
every reading was refused still rendered a green tick, and a re-scan
where every row turned out to be a duplicate raised a success toast for
zero saved readings. They now render the outcome the server actually
resolved: a tick only when everything landed, a warning when part of it
did not, an error when none of it did.
### Grading you against your own numbers
The Health Score graded your weight against a target nobody set, silently
computed from your height and never shown anywhere. If you had entered
your own weight target on the targets page, the score ignored it. Both
the targets page and the score now read the same target you actually
set, and an account with no target of its own gets a plain trend reading
instead of a number graded against a hidden goal. The score's weight row
now says outright which of the two it used.
The Health Score itself is rebuilt on the same coverage-and-provenance
contract the rest of the app's derived metrics already use. Every pillar
now says what went into it and what did not, weights redistribute openly
when a pillar has nothing to grade instead of quietly changing what the
number means, and the card explains itself the way the other Insights
cards do. Because both of these changes move the same number, they are
explained once: the score carries a version marker, and the first delta
you see after updating is suppressed rather than reported as a real
day-to-day change, with a note on the card saying why.
Steps, active energy, distance, and flights now compare today's running
total against what is typical for you at this same hour, not just
against your latest reading, which never meant anything for a metric
that only makes sense as a full day. It needs at least two weeks of your
own history before it says anything, and until then it says so honestly
instead of guessing from three days of data.
### What was asked for, and built
An account-defined custom metric can now feed the same correlation engine
your built-in metrics already run through, so a value nothing else in the
app tracks can be checked against your own outcomes rather than sitting
in its own silo. Caffeine and nicotine join alcohol as taggable factors in
that same engine. A correlation or crosstab finding can be dismissed once
it has been looked at, and stays dismissed instead of recomputing itself
back into view on every run.
Three facts about you, smoking status, alcohol pattern, and shift or
rotating-schedule work, can now be recorded as dated, correctable entries
rather than buried in free text, with their own switch for whether they
reach the Coach's prompt at all.
The "worth a look" rail on the dashboard can now be trimmed to the kinds
of item you actually want to see there. Turning a tile off there only
hides it; it does not touch the medication reminder itself, which keeps
firing on its own channel regardless of what the hero card shows.
### Two defects, found by people who read the code and the numbers
A workout synced from Google Health with a sport type the app's mapping
table did not recognise lost that type outright and rendered as
"Other," identical to a workout that genuinely had no category. The
original label is now kept on the row, the same way three other
providers already keep theirs; a full re-sync repairs workouts already
written under the old behaviour.
A same-time baseline band for a metric that cannot go negative, such as
step count, could report a lower edge below zero. One call site is now
required to name which metric it is drawing a band for, so the floor
comes from that metric's own declared range rather than being patched in
by hand at each caller.
**Reminders for a multi-dose schedule stopped matching the dose they were
meant for.** The reminder tick computed one window length for a whole
schedule and applied it to every time slot in it, so a twice-daily
schedule gave its first dose a ten-hour grace period nobody configured
while a single-dose schedule with a narrow window escalated correctly but
then had almost no room to register a dose logged late. Each slot now
resolves its own window: an explicit per-dose override first, then the
schedule's own grace setting, then the same default every other
unconfigured dose gets, bounded so two doses in one day can never share a
window.
A Fitbit workout kept only its canonical category and dropped the raw
activity name and type id it arrived with, unlike every other provider
this app syncs from. An unrecognised Fitbit activity had nothing left to
diagnose it by. Both are now kept on the row, and carried through backup
and restore the same way.
### The backup
Your custom metrics and their readings, and the encrypted health-profile
facts above, are now carried by export and restore. Neither was before;
a restore silently rebuilt an account without either and reported
success. The hourly shape behind the same-time baseline is also carried,
because past a short grace window it is the only copy of that curve
anywhere. And the summary shown after uploading a backup now counts every
record class it restores, including the nutrient day totals it was
quietly leaving out of its own total.
### For operators
Four migrations. `0283` adds the intraday cumulative profile table,
`0284` adds the effective-dated health-profile fact table and its
inclusion enum, `0285` adds correlation-pattern identity, `0286` seeds
the caffeine and nicotine mood tags. None deletes or rewrites a
measurement.
### Credit
Reported by @lutzkind (#613, #614, #616, #617, #649, and a long run of
earlier reports this release also carries forward). Reported by
@tarantila, directly rather than on the tracker: the Google workout-type
loss, and a below-zero baseline reading that turned out to affect
other metrics once someone went looking. Built by @mathewcsims
(Refs #662): the per-slot reminder window fix.
### Dedication
This release is dedicated to the people who reported something, asked for
something, or noticed something and said so: @balajiv113, @ChaosExAnima,
@doenke, @DrGithubble, @Flieger37, @k-nacion, @kevinpapst, @livelylinux,
@lutzkind, @Manschk3rl, @mathewcsims, @muhdusama, @Nazza01, @nhemnt,
@StefanIndustries, @TalesFromTheStack, @tarantila, @Uvenstedt, and
@zet1200. None of you owe this project anything, and all of you gave it
time anyway.
@lutzkind gets a line of his own. Twenty-four issues so far, several of
them arriving with the broken code path already traced, and a habit of
pushing back on a design decision until it actually gets better. More
than one feature in this release started as his idea, not mine, and I
did not expect that from a project I built to track my own blood
pressure.
@StefanIndustries was the first person to send this project money. What
was sent stays between us; the amount was never the point. Deciding
this was worth paying for, and saying so out loud, was not something I
expected from a self-hosted app nobody asked me to charge for.
This release carries a good part of what came out of that list, among
other things a workout category that two providers were silently
dropping, a below-zero reading nobody else had caught, and a reminder
window that ignored half its own schedule. It does not carry all of it.
What is still open stays open because it is waiting its turn, not
because it got set aside.
More is welcome, from anyone: an idea, a rough thought about how
something ought to work, a screenshot of a number that looks wrong.
Especially the number that looks wrong.
## [1.33.2] — 2026-07-28
### The cycle-symptom fix in v1.33.1 did not work
v1.33.1 said it had fixed custom cycle symptoms in backups. It had not. The
fix was real in three places and missing from the one that decides what ends
up in the file. The code that reads your cycle data returned the symptoms you
created yourself. The file format declared a field to hold them. The restore
knew how to put them back. But the step that assembles the file copies its
contents across one field at a time, and that list was never extended, so
every backup written since carried an empty list of custom symptoms.
The same release also replaced a silent drop with an error. Before it, a
backup referencing a symptom the restoring instance did not have lost that
one link and finished. After it, the restore stops and reports a failure.
Together with the empty list, that took an account with a symptom of its own
from losing a single link to being unable to restore at all. That is the
group the fix was written for.
Nothing in your stored data was damaged. But a backup written by v1.33.1 or
earlier does not hold the symptom definitions you created, and restoring one
of those files on this version will fail at the first day-log referencing
one. Take a fresh backup after updating and keep it alongside the older
files.
The assembly step no longer lists fields by hand. It copies each section
whole, so a field added to a section reaches the file because of the way the
code is written rather than because somebody remembered. Both ends of this
were covered by tests that passed for the whole time it was broken, and
nothing covered the join between them, so there is now a test that writes a
real backup for an account with a symptom of its own, removes that symptom
from the database, restores the file, and checks the row and its day-log link
came back.
## [1.33.1] — 2026-07-27
A patch release about the same thing as the one before it: places where the
app knew something and said something else.
### Two that change what you receive
**Medication reminders arrived every fifteen minutes.** The rule that decides
whether a reminder has already been sent was keyed on a ledger that could not
tell one dose slot from another, so an overdue dose kept re-announcing itself
for as long as it stayed overdue. One account was getting roughly fifty mails
a day. A reminder now goes out once per dose, per escalation step, per local
day, and two doses due at the same time no longer replace each other on the
lock screen.
**"Handled by the app" now silences only the app.** The setting that lets the
iOS client take over medication reminders was suppressing every channel, so
switching it on quietly turned off Telegram, ntfy, webhooks, email and web
push as well. That was never what it said it did. If you have been using it as
a general mute, those channels will start delivering again, and the setting
does what its name says.
### Three that were reported
Someone self-hosting could not use an OpenAI-compatible gateway. LiteLLM,
OpenRouter and vLLM all speak the OpenAI wire, but the provider list had no
entry that honoured a base URL of your own, and an earlier answer from us said
otherwise. There is now a provider for exactly this, with its own key, model
and endpoint. The plain OpenAI entry stays pinned to OpenAI, which a test
enforces, so a key meant for one never travels to the other.
Timestamps followed the browser rather than the profile in four places. The
worst of them: a shared report downloaded as a PDF could carry a different
date than the page it was downloaded from, for the same reading. Charts also
drew their week and month boundaries in one fixed timezone, so an evening
reading landed in the wrong bar for anyone living elsewhere.
Some dashboard tiles could be seen and not switched off. The mobile app draws
a handful of widgets the web has no render path for, and it writes them into
the same stored layout, so an account ended up holding rows it never set and
could not change from anywhere. `bmi` was one of them. They now appear in the
dashboard settings in their own labelled group, with the same show and hide
controls as everything else and no reordering, because placement stays the
mobile app's. The group only shows up for widgets your layout actually holds,
so an account with no phone client sees nothing new.
### The backup
Three things it carried and could not restore, or could not carry at all.
A cycle symptom you created yourself was never included, so on restore its
key resolved to nothing and the link was dropped without a word. Three lines
away in the same function, an unknown cycle reference threw an error. Same
kind of missing reference, two different answers.
A mood factor you created yourself had the opposite ending: the restore
looked only for the built-in ones, found your own missing, and stopped. The
file was complete and unusable.
Nutrient day totals have been written into every backup since v1.29 and were
discarded on restore, silently, while the restore reported success.
What a backup contains is now declared in one place, with a written reason
for every record type left out. Around thirty types are still carried by
neither the file nor the restore, and they are named there rather than left
to be discovered. The export card no longer promises "every record you own",
because it does not.
### For operators
Two migrations. `0280` adds the gateway provider's columns, `0281` turns the
integration ledger's single failing-part slot into a set. Neither deletes or
rewrites a measurement.
A provider that syncs several things separately can now remember every part
that is failing at once. Until now the last one to report overwrote the rest,
which is the limit named in the v1.33.0 notes and not fixed then.
Self-hosters can point `GEOLITE2_DIR` at their own GeoLite2 databases. The
variable was read by the code and missing from the compose whitelist, so it
never reached the container. The message in the admin area used to tell
self-hosters to set a secret in this repository, which is not something they
can do.
### Credit
Reported by @k-nacion (the OpenAI-compatible gateway and the timezone
rendering), @mathewcsims (the unreachable GeoLite2 setting, found by reading
the strings inside a running container), @Nazza01 (the dose-history import),
@doenke (the table under the chart, and for narrowing his own request until it
fit one release) and @lutzkind (the dashboard layout).
## [1.33.0] — 2026-07-26
Eight defects, and every one of them had the same shape: something failed
reliably, the code recorded it precisely, and no person ever found out.
**"Delete All Data" deleted 13 tables out of 92 and reported success.** What
survived included mental-health questionnaires, lab results, the menstrual
record, allergies, family history, the whole document vault, every AI
conversation, and clinician share links that kept working. On the account row
itself, 11 of 119 columns were cleared, so name, insurer, insurance number, home
coordinates, profile photo and every integration credential stayed. The wipe now
covers everything the account owns except what is needed to sign in again, the
list is derived from the schema so a newly added table cannot quietly survive,
and the confirmation says what will go before you agree rather than after.
**A device sync failed 1149 times without anyone being told.** The escalation
had no rung above "temporary", so a connection could fail hourly for weeks while
the card read "connected". Worse, providers that sync several things separately
had one healthy part erase the error recorded by a broken one, twice an hour,
including a connection that had already been parked for a dead credential. A
failing connection now shows how long it has been failing, escalates after a
day, and stops retrying after two with a reconnect button.
One limit worth naming rather than leaving to be discovered: the ledger
remembers a single failing part per provider. If two parts fail at overlapping
times, the recovery of the later one still clears the error of the earlier. That
is narrower than the case this fixes, and it is not closed.
**The Environment module had never once saved a row, for anyone.** One
declaration in 49 was missing the line that maps it to the database, so every
write referred to a type that does not exist. The job failed every night and
recorded that failure faithfully, and no code anywhere reads a failed job. Both
halves are fixed: the module writes, and a failing background job now names
itself on the admin page and on the affected screen. Past days can be fetched
from the weather source; the nightly job fills the last week by itself and the
backfill button covers the rest.
**Sleep was counted twice.** A device that re-exports a night it already sent
left two overlapping in-bed periods, and both were added. One night read ten
hours in the headline and twenty in the legend. The same fault inflated the
time-asleep figure itself. Every stage total is now the measure of its own
period rather than the sum of overlapping ones.
**Twenty-two controls could destroy something without asking.** Among them a
single tap that cleared the allergy list along with a text note, and a switch
labelled "Custom range" that deleted the stored range when switched off. Every
destructive control is now registered, and a new one cannot ship without being
classified.
**Medication stock was written off that had not expired.** The thirty-day clock
that applies to an opened injection pen was being applied to a blister of
tablets, so pressing out the first tablet started a countdown and a month later
the rest were declared unusable. The container kind was recorded and never read.
Pens and ampoules keep the clock; blisters, bottles and inhalers do not. A
repair returns stock that was written off, and the dates that drive it can now
be seen and corrected on the item itself.
**Insights scrolled sideways on every phone.** Two guards were watching for
exactly that and both measured the document, while the app scrolls inside an
inner area that absorbed the overflow. They now measure what actually moves.
**A third value for gender was accepted by the app and missing from the
published contract in five places, and dropped or coerced in nine more.** A
fitness calculation scored anyone without a recorded gender using the male
coefficients, and the doctor report labelled any unrecognised value as
non-binary. Every place the field is read, written, documented or displayed was
gone through.
### Four additions
Medication history from a widely used iOS export tool can now be read directly,
as the file actually is, rather than converted by hand. The scheduled time and
the time taken stay two separate facts, which is what a hand-mapped file
destroys. Every column is either used, reported, or named as not read, and you
see which before you import. Seven status values are ruled on individually: only
"taken" and "skipped" become doses, and a snoozed reminder or an unanswered
notification is counted and named rather than turned into a decision you never
made.
The Coach can tell "you never recorded this" apart from "you recorded it, but
outside the range I looked at". Someone with 1,597 imported glucose readings
from April 2024 was told there was no glucose data at all, and the Coach then
changed the subject, because one reason code covered both situations and the
instruction to move on fired on both. It now says what is actually true and
offers to look further back.
A mood tag can now be paired against the next morning's resting heart rate and
heart-rate variability. Until now a tag such as alcohol had three channels to
land in, and the only one that looked at the following day needed a wearable
recovery score that plenty of accounts never record, so the question of what a
tagged evening does overnight had no path at all. The difference is printed
without a colour verdict, because a rise reads as worse on one row and better
on the next, and an association cannot carry that judgement.
Every chart on the metric pages now carries the points it drew as a table
underneath, collapsed by default with the count in the button. It is handed the
same points the chart draws and the same formatter the tooltip uses, so a cell
and a tooltip cannot print two different numbers for one reading. The caption
names the grain, so a weekly row says it is a weekly average, and units sit in
the header rather than in every cell, which is what you want when the rows go
into a spreadsheet.
### For operators
Four migrations. `0276` adds retention indexes, `0277` adds failure-streak
tracking, `0278` scopes the intake import key per account, `0279` repairs
inventory that was wrongly expired. None deletes or rewrites a measurement.
### Credit
Reported by @lutzkind (sleep double-counting, the gender contract, the
Environment enum, the Coach retrieval window, the next-day pairing for mood tags
in #616, and a run of earlier reports), @Nazza01 (the intake import, plus the
export file and the format documentation that made reading it possible),
@doenke (#636, the table under the chart) and @DrGithubble (the unit and
precision work that led here).
Two of these defects were found because someone outside this project read the
code and said what was wrong. The Environment module had been broken for every
user since it shipped and nobody inside noticed.
## [1.32.39] — 2026-07-26
The doctor report now carries what you chose, and nothing else.
**Ten of the twenty switches did nothing.** The panel offered controls for
oxygen saturation, body fat, body composition, resting heart rate, heart-rate
variability, VO2 max, steps and distance. Switching them off changed nothing:
the value was folded away before it reached the report. Two more claimed less
than they did, and the medication switch left the dose-by-dose record in place
while appearing to remove the drug list.
**Allergies and family history went out regardless.** They were read straight
from the database in the export route, so a report could exclude them and the
structured file in the same download would still contain them.
**The mental-health questionnaires went out without being asked for.** The
depression, anxiety, wellbeing and sleep scores reached the structured export,
the automation interface and a shared link, gated only by a module that had
been switched on by default some releases ago. They now have their own entry
and are off unless you pick them.
**The cycle export shipped ten other things.** The card that promised to export
only cycle data sent an empty request, and an empty request fell back to the
defaults, so it also carried weight, blood pressure, pulse, oxygen saturation,
body fat, the medication list, adherence, lab results, allergies and family
history. The comment above that code said every other section was off.
**What replaces all of it.** One picker, on both surfaces that choose a scope.
Twelve groups, each releasable or excludable as a whole and openable down to
the individual value. All 77 measurement types and 13 record sections are in
it, each in exactly one group. Sensitive material sits below a divider with no
group control, so no single click can turn on more than one of those at a time.
Nothing is preselected. A new report starts empty and one button fills in what
a general visit usually needs, which you can then change. Generating is not
possible while nothing is chosen.
A metric added to the app in future cannot appear in a scope you saved
earlier. That is not a promise, it is a compile error: a new measurement type
without a group breaks the build, and every path that produces a report has to
name where its scope came from.
**Share links.** A link can now be fetched as a PDF or as a structured file
behind the same passphrase that already protects the view, which is what a
practice actually does with one. The old promise of an interface that never
existed is gone. Links created before this release carry no explicit choice
and are therefore closed; each one can be reissued in a click with its label,
period and expiry carried over.
**The written summary has left the report.** It could not be checked against
what you chose, and prose whose scope cannot be shown has no place on a
document whose value is exactly that.
**Structured export corrections.** Someone who records their gender as
something other than female or male was exported as female. FHIR has a code
for it and it is now used, and an unrecognised value is left out rather than
guessed. The capability document described a resource twice and omitted
another that was actually served; both corrected.
**Also in this release.** Roughly one page load in three showed raw text keys
for a moment instead of words, because the language file was racing the
application bundle and losing. It is now loaded before the first render.
**Converted values were shown with more precision than they have.** A weight in
pounds could read 207.896 where the conversion declares one decimal place. The
rounding was defined for every conversion and read by exactly one screen out of
all the ones that display a converted value, so everywhere else the raw result
of the multiplication went straight through. It now happens where the
conversion happens, so a surface cannot render an unrounded value even if it
forgets to ask. Metric values are deliberately untouched, because the metric
render is meant to be exactly what is stored, and the entry path keeps full
precision so a saved value cannot drift a little further each time.
Thanks to @DrGithubble, who reported this on #627 after confirming the earlier
unit fix. It turned out to be the second half of the same defect they had found
the first time.
**For operators.** Migration `0275` stores the new scope and removes the old
preferences. Existing share links are closed by it and need reissuing.
## [1.32.38] — 2026-07-25
Blood-glucose readings from anything other than the manual entry form were
never saved.
A rule added years ago required every blood-glucose reading to carry a
context: fasting, after a meal, random, or bedtime. That is a fair thing to
ask of someone typing a reading in by hand. It is impossible for a sensor,
which measures every few minutes and cannot know what you were doing.
So every automated path failed. A CSV import reported the rows as skipped and
still showed a green tick. Nightscout counted them as failed rows and carried
on. Apple Health has no context field at all, so not one of its readings could
ever have been stored. The same for readings written through the automation
interface, through Telegram, and through the JSON importer. None of it was
visible: no error reached anyone, and the affected charts and panels simply
stayed empty as though nothing had been measured.
A reading with no context is now stored with no context, which is what it is.
Where a context is given it is still checked. Manual entry still asks for one,
because the form offers the choices and nothing is lost by asking.
Absence is now shown as absence: a reading without a context is left out of the
per-context breakdowns rather than being counted as one of them.
**Imports no longer claim to have worked when they did not.** A run that wrote
nothing is shown as a failure. A run that wrote some rows and skipped others is
shown as a partial result. Only a run that actually landed something shows as
done. Skipped rows are grouped by reason with a count instead of listing the
first fifty identical lines, and the per-row detail is still there behind a
disclosure. This covers the CSV, JSON and Apple Health cards alike.
Reported by @lutzkind, who traced it to the parser, ran it in isolation to rule
out the timestamp and unit handling, and checked the database afterwards to
prove nothing had been written. Files that were rejected can simply be imported
again.
**For operators.** Migration `0274` relaxes the constraint. It adds no column
and rewrites no row. Readings that were refused in the past were never stored,
so they need a fresh import from the source.
## [1.32.37] — 2026-07-25
Two ways a record could quietly lose data, both closed.
An identifier that a client sends along with a reading is meant to say "this is
the same reading you already have". One client was sending a value that changed
every time the app restarted, so nothing ever matched, and every sync created a
fresh copy. On one instance that produced 23 medications nobody had ever taken,
none of them with a dose or a single logged intake, because the doses were
looking for a medication under an identifier that no longer existed.
The server now refuses an identifier that cannot possibly be stable, with a 422
that says what is wrong instead of quietly making duplicates. A batch rejects
only the offending entry and still accepts the rest, so one bad row cannot stop
a sync. Twelve entry points check it. The identifier shapes that real apps and
devices actually send are pinned by tests, so a future rule cannot get greedy
and start dropping valid data.
The second one was worse and had no symptom at all. The repeat-protection key
on a medication intake was unique across the whole database rather than per
account. Two people generating the same key independently meant the second one
could not save their dose at all. The database refused it, and the intake was
gone. The key is now unique per account, and every lookup asks who it belongs
to. Recording an intake through the automation interface without sending a key
had a related problem: the first one saved and every one after it was discarded
as a repeat. Also fixed.
The migration does not touch any existing intake. It relaxes a rule rather than
tightening one, so nothing that was allowed before becomes disallowed now.
## [1.32.36] — 2026-07-25
Nothing here changes what you see. This release is about the checks that run
before a release goes out.
A recurring shape of mistake in this project is a half-built feature: something
is stored, or sent to the app, and the part that was supposed to use it never
gets written. Each end works on its own, so nothing complains. Three checks now
run on every build and refuse that. One walks the fields the app receives about
your account and insists each one is actually read somewhere. One compares the
measurement table against what a backup writes out, so a new column cannot go
missing from your backup file. The third fixes a check that had gone quiet: it
was meant to find translation entries no screen uses any more, but one line of
it had been waving through several thousand entries at once for a long time
without anyone being able to tell.
With that check honest again, 149 leftover translations turned out to belong to
screens that were rebuilt or removed some time ago. They are gone from all six
languages. Nothing that is on screen today was touched.
## [1.32.35] — 2026-07-25
The doctor report stops asking for your practice name every time.
You type a clinic or practice name onto the cover of a health record export,
and until now that was all it did. The field emptied itself again the moment
you left the panel, so next month's report started from a blank line. The name
you used last time is now kept and filled in for you. Clearing the line works
the same way and is remembered too, so a cover without a practice line stays
without one. The name is saved only once the report has actually been
produced, which means an export that fails partway leaves the previous value
alone.
A backup now carries the origin of your daily totals. Steps, distance and
similar figures that arrive from Apple Health as one number per day are stored
with a note of where that number came from, so a rough estimate read out of an
archive can never overwrite a total the phone measured itself. That note was
missing from the backup file, so a restore brought the figures back without it
and the protection went quiet. It rides the backup now. Restoring an older
backup written before this fix marks the affected daily totals as unclassified
instead of leaving them ambiguous, which is the state a later sync knows how
to repair.
Migration 0272 drops two columns that were written and never read. They held a
contributor count and a source fingerprint for archive derived daily totals,
and no screen, export or repair path has ever looked at either. The figures
themselves and the origin note above are untouched.
## [1.32.34] — 2026-07-25
Housekeeping on the interface. Most of it you will not see.
The sections on the insights overview had each been given their own card frame
by hand, and the copies had drifted apart on the one thing nobody thought to
write down: how much room to leave between the blocks inside them. Some used 8
pixels, some 12, one 16, all sitting one under the other in the same column.
They now share a single frame with a single rhythm, so a few of those cards
open or tighten very slightly and all of them pick up the same faint shadow
their neighbours already had.
When a metric page cannot load, the message now reads in the normal text
colour with a warning glyph beside it, instead of the grey reserved for
timestamps and units. A failure is what that surface has to say at that moment,
so it should not look like a footnote.
The rest is invisible from the outside. The sleep composition card uses the
same title row as every other card, a handful of one-off text sizes and
paddings went back onto the scale the rest of the app uses, and the spacing
rule that was already enforced in the build now has nothing left to catch.
## [1.32.33] — 2026-07-25
The moodLog integration is gone. It bridged mood entries to and from a separate
self-hosted mood app back when HealthLog had no real mood module of its own.
That stopped being true a long time ago: mood is tracked natively here now,
with tags, rated factors and notes, and the bridge had nothing left to add. Its
settings card was withdrawn a while back and the rest of it kept running in
the background against an upstream nobody was pointing at any more. This
finishes the job properly.
- **Your mood entries are untouched.** Every entry stays exactly where it is,
including the ones that originally arrived through moodLog. They keep their
source label, they still show up in the mood list, the filters, the charts,
the correlations and the doctor report. Nothing was deleted and nothing was
relabelled. Removing the pipe does not remove what came through it.
- **What was removed.** The hourly pull, the inbound webhook, the outbound
push, the per-account credentials and webhook secret, the admin toggle, the
entry on the integrations status feed, and the message strings behind the old
card. Migration `0271_drop_moodlog_integration` drops the six columns those
surfaces used and clears the retired sync schedule. It names no mood table.
- **One less admin switch that did more than it said.** The instance-wide
toggle was described as controlling mood-log reminders. It actually shut off
the whole integration, quietly, with no other visible effect. It is gone
along with the thing it gated.
If you were still relying on the bridge, export your mood data from the other
app and import it once; the entries land in the same place they always did.
## [1.32.31] — 2026-07-25
The integrations page could tell you a connection was failing. It could not
tell you a connection had stopped trying. Those look the same from the outside:
the page keeps showing whatever error was recorded last, and it keeps showing
it for as long as nobody looks, because nothing anywhere read how old the last
attempt actually was. This release adds that reading, and every status on the
page now comes from it.
- **A sync that has stopped running is labelled as stopped.** When a connection
has not even attempted to sync for a day, it says so, with how long it has
been. Previously it kept displaying the last error it managed to record, which
reads like something that is still retrying.
- **Per data type, when a value last arrived.** Each connected card now opens a
short list: every data type that connection has ever delivered, and how long
ago the newest one came in. A type that has gone quiet for two weeks while the
rest of the connection is healthy is called out, which is the shape a single
broken pipe takes: one revoked permission, one endpoint that started returning
nothing, while the sync itself keeps reporting success. Types the connection
has never delivered are simply not listed. Nothing is invented.
- **Apple Health has a staleness threshold.** Any timestamp at all used to paint
the green "data received" badge, so a phone that stopped delivering three
weeks ago looked exactly like one that delivered this morning. After a week of
silence the card now says so and points you at the app's Apple Health
permissions. It also lists the same per data type detail as the others.
- **A configured connection with no card of its own now appears.** If your
account has a connection set up that the page has no card for, it gets a plain
status row instead of being left off entirely. That is a structural fix: a
connection can no longer be running, failing and invisible at the same time.
- **The status colours mean one consistent thing.** Red is now reserved for the
cases your own action fixes, which is reconnecting or resuming. An upstream
outage or a rate limit carries the amber tone instead, because clicking
reconnect against a server error has never helped anyone. Every card uses the
same mapping. Three of them used to disagree with each other on the same page.
- **Nightscout reads the same status source as everything else.** It was the
last card fetching its own status separately, which is why it could show a
different colour for the same condition.
The assistant tools report the new verdict too, so asking why your data looks
stale gets the real answer instead of "connected".
## [1.32.30] — 2026-07-25
Height was the last value still asked for in centimetres regardless of the
setting. Both previous releases closed with a note saying so. It sat outside
the fix because height is not a measurement at all: it is a field on your
profile, so the conversion the reading surfaces go through never reached it.
- **Height is entered in the unit you use.** With imperial selected, the
profile card and the onboarding step ask for feet and inches in two boxes,
the way a person actually states a height. With metric selected the single
centimetre box is exactly what it was.
- **The value you type comes back.** Feet and inches are kept to the nearest
whole inch, which converts to centimetres without a remainder, so saving
5 ft 11 in and reopening the page shows 5 ft 11 in again. The limits the
fields offer are the imperial equivalents of the ones the server enforces,
rounded inward, so a height you are allowed to type is never refused on save.
- **Storage is unchanged.** Height is still kept in centimetres, the API still
sends and receives centimetres, and BMI and the weight bands still work from
that same value. A metric account's stored height is untouched by this
release.
- **Switching the unit keeps what you were typing.** The unit selector sits on
the same card as the height field, so flipping it re-expresses whatever is in
the field rather than clearing it.
With this the preference reported in #627 reaches every value the app shows you
or asks you for.
## [1.32.29] — 2026-07-25
Fitbit re-scores a night hours after you wake up: blocks get split, merged or
relabelled. HealthLog identified each block by its position in the list, so a
re-score that renumbered the blocks looked like a set of brand new ones. The
previous version stayed on file, nothing removed it, and from then on the night
counted twice. It was quiet: the sync kept reporting success, because the sync
was in fact working.
- **A re-scored night no longer double counts.** Each sleep block is now
identified by the moment it starts, which does not move when Fitbit rescores
the night. A block that changes from light to deep updates in place instead
of leaving the old copy behind, and anything the new scoring dropped is
cleared from that night's window before the new blocks are written.
- **Nights already affected repair themselves.** A one off pass runs once per
connection after the update and rereads the past year of sleep, so the
duplicates collapse without you doing anything. It covers 365 days, the same
horizon the Fitbit history import uses. Nights older than that keep whatever
they already hold; they cannot gain new duplicates, since Fitbit does not
rescore year old nights. Migration `0270_fitbit_sleep_repair_marker` records
which connections have been through the pass.
- **Rate limits are handled instead of ignored.** When Fitbit asks for a short
wait, the request now waits and tries again once. A long wait is refused
outright and left to the next scheduled run, so one throttled account cannot
hold up everybody else's.
- **Fewer pointless writes.** Fitbit rereads the last 24 hours every hour. Rows
that came back unchanged were rewritten anyway, which showed up as needless
traffic for the iOS app. Unchanged rows are now left alone.
## [1.32.28] — 2026-07-25
Oura used to look back exactly one week and no further. If the sync had been
broken for longer than that, or if the ring sat unused and the older nights
only reached Oura's cloud later, those days fell outside the window and were
never imported. Nothing said so, because a sync that finds nothing still counts
as a sync that worked.
- **Oura catches up after a gap.** The window is now measured from the newest
Oura reading actually on file rather than from a fixed week. A ten day gap
pulls eleven days on the next run and closes itself; a month is the ceiling.
When data is arriving normally the window stays at a week and nothing about
the hourly run changes.
- **A fresh Oura connection imports a month** instead of a week. Deeper history
is a separate question and is not part of this release.
- **Sync now for Oura, Polar, Strava and Nightscout.** These four had no way to
ask for a sync; they only ever ran on their scheduled pass. Each connected
card gets the same button WHOOP and Fitbit already had, limited to a few runs
per minute.
- **Connecting starts the first sync straight away.** Oura, Polar and
Nightscout used to sit empty for up to an hour after connecting, waiting for
the next scheduled pass. The first pull now runs on connect, and the schedule
stays as the fallback.
## [1.32.27] — 2026-07-25
Target and threshold panels now follow the metric and imperial preference. The
previous release listed this as the one place still reading in kilograms and
Celsius: with imperial selected a weight chart read in pounds while the target
reference right below it read in kilograms.
- **Target ranges read in your unit.** On the weight insight page the target
band, the range bar, the status pill, and the 30 day average all read in
pounds when imperial is selected. Body water and bone mass follow the same
rule in the threshold settings.
- **Editing a target works in the unit you see.** The target editor and the
threshold settings seed their fields from the stored value converted to your
unit, check what you type against limits expressed in that same unit, and
convert back before saving. A value typed at the displayed limit is accepted
instead of being rejected against a kilogram bound you never saw. Saving and
reopening shows the number you typed.
- **Storage is unchanged.** Targets are still kept in canonical SI, and a metric
account's saved values are untouched by this release.
- **Known remaining.** Height is still entered in centimetres.
This finishes the job @DrGithubble started in #627.
## [1.32.26] — 2026-07-25
The metric and imperial display preference is now applied everywhere your
weight, body composition, waist, and temperature readings appear. Until now the
setting was saved but almost nothing read it, so the numbers stayed in
kilograms and Celsius regardless of the choice. Thanks to @DrGithubble, who
reported this in #627 and traced it himself, down to the hardcoded kilograms in
the entry form and the missing imperial branch in the display transform. His
follow-up was the more useful half: a reading genuinely tagged in another unit
was being relabelled by any surface that printed the type's canonical unit. That
is what turned a one-screen fix into a proper one.
- **Weight and body composition follow the preference.** With imperial
selected, weight, muscle mass, body water, bone mass, fat mass, and grip
strength read in pounds across the dashboard tiles, the charts, the record
list, the insight pages, and the coach read strip. Body temperature, skin and
wrist temperature read in degrees Fahrenheit, and waist reads in inches.
Storage is unchanged: every reading is still kept in canonical SI, and a
metric user sees exactly the same numbers as before.
- **Manual entry converts at the point of entry.** When you log or edit a value
with imperial selected, the field is labelled in your unit and the number you
type is converted back to canonical on save, so a pound reading is stored as
the correct kilogram value rather than a mislabelled one.
- **A logging tool can no longer store a foreign unit.** The measurement logging
tool used by connected assistants now converts a recognised unit hint (such as
pounds or Fahrenheit) to canonical, and refuses anything it does not
recognise, so a reading is never saved under the wrong unit.
- **Known remaining.** Height is entered in centimetres for now. Target and
threshold panels also still read in kilograms and Celsius, so with imperial
selected a weight chart reads in pounds while its target reference below it
reads in kilograms. The stored values are correct either way; both are
separate follow-ups.
## [1.32.25] — 2026-07-24
Mirrored medications are now marked as such, and the server limits how many
medications one external source can mirror so a client bug can no longer fill
the list with duplicates.
- **Mirrored medications are marked on the list.** The medications list now
reports whether each entry was mirrored from an external source (today Apple
Health) or created in HealthLog directly. A synced row is no longer
indistinguishable from one you added yourself.
- **One external source can no longer flood the list.** The server refuses to
mirror more than 30 medications from a single external source per account. A
client that keeps re-sending the same medication under a changing identifier
can no longer create a fresh row on every sync. A repeat of a medication
already mirrored still resolves to the existing row, and medications you add
yourself are never affected.
## [1.32.24] — 2026-07-24
Polar sync is more resilient when one data type is unavailable. A single
collection that a device or account cannot serve no longer blanks the rest of
the Polar data on every hourly check.
- **One unavailable Polar data type no longer blocks the others.** SpO2 on a
watch without the sensor, or a data type an account is not entitled to, used
to fail the whole Polar sync and leave recharge, sleep, and activity empty.
Each collection now imports on its own, so the ones that are available still
come in and only the one that failed is held back.
- **A failing Polar collection is named on the integration status.** When a
collection cannot be read, the sync is recorded as failed with the failing
collection named, instead of showing a healthy state over missing data.
- **Unexpected Polar response shapes are logged, not read as empty.** If Polar
returns a body that does not match the expected shape, it is now recorded for
diagnosis rather than silently treated as "no data", which previously could
mark a sync healthy while importing nothing.
## [1.32.23] — 2026-07-24
Sync status now tells the truth for the connected wearables and CGM sources. A
-205
View File
@@ -1,205 +0,0 @@
# Contributor notes
Working notes for anyone (human or otherwise) editing this repository. Skim before the first PR; refer back when a convention question comes up. Pair with `README.md` (the public face) and `CHANGELOG.md` (the release history); this file is the maintainer's operating manual.
## What HealthLog is
Self-hosted personal-health-tracking PWA: weight, blood pressure, pulse, body composition, glucose, sleep, mood, medication compliance. Withings + Apple Health sync, multi-provider AI Insights (BYOK or local), doctor-report PDF, native iOS client in public beta. Single `docker compose up`, Postgres-backed, AES-256-GCM at rest. PolyForm Noncommercial 1.0.0 (AGPL-3.0 through v1.15.18). Current line: v1.20.x.
## Voice and privacy
Hard rules for every committed artefact — commit messages, CHANGELOG entries, PR bodies, code comments, GitHub release notes, docs site copy. Treat these as the project's public face.
- **Voice.** English. Maintainer prose: present-tense, imperative for commits, factual for prose. No marketing tone, no apology tone, no "wow what a cool feature" framing. The voice is one human writing notes for the next.
- **No assistant / tooling references.** Do not surface AI, agent, assistant, copilot, marathon, phase, wave, round, or session vocabulary into anything that lands on GitHub — including commit messages and trailers. Commits carry no `Co-Authored-By` tooling trailer; they are authored by the maintainer's git identity alone.
- **No personal data.** No real names (the maintainer's, a reporter's, or anyone else's). No live health figures, no live tenant identifiers, no real-account anchors. Generalise to "a self-hoster", "a user", "an operator", "an authenticated session", etc. The internal `.planning/*` files are gitignored and exempt; everywhere else in the repo, no.
If a draft commit message or release note would violate one of the three, rewrite it before committing. The `git log` is more durable than today's task.
## Branch and release model
`main` is the single trunk and is always releasable. There is no long-lived `develop` branch — the former one was retired and archived as the tag `archive/develop-pre-v1.5` (it carried no work absent from `main`; a long-lived integration branch has no role in a single-maintainer shop and only accrued back-merge debt). This is trunk-based development with release tags: short-lived branches off `main`, work always converges back onto `main`, and the immutable `vX.Y.Z` image built from a tag on `main` is the one source of truth — including for the OpenAPI contract the iOS client consumes.
- **Trunk.** `main` always builds, passes CI, and is deployable. Every commit on it is a release commit (`chore(release): vX.Y.Z`), a release-branch merge, or a maintenance commit (docs, gitignore, audit findings) applied directly.
- **A release** is built on a short-lived `release/vX.Y.Z` branch cut off `main` — built wave-by-wave, then opened as a PR `release/vX.Y.Z` → `main` with green CI, merged (merge-commit), and tagged `vX.Y.Z`. The tag push triggers `docker-publish.yml` (GHCR multi-arch + `:latest`). Deploy is manual (see `docs/ops/deploy.md`); after a deploy, verify `/api/version` (it returns `version` + `buildSha` + `builtAt`) on every target, since the `:latest` pull is the source of truth, not the queued deploy status. Delete the release branch after merge.
- **Small standalone changes** (a single fix, a docs touch) can go through a short-lived `fix/X` / `feat/X` branch + PR, or directly onto `main` when the maintainer chooses — `main` is the target either way.
- **Hotfixes** branch from `main` and merge straight back to `main`. There is no second long-lived line to forward-port to, so the old back-merge debt cannot recur by construction.
- Never force-push `main` or any release branch. Never skip hooks (`--no-verify`). Never `--no-gpg-sign` unless explicitly asked.
## Semver
Prefer patch bumps. A release dominated by bug fixes plus additive features without breaking changes ships as `vX.Y.Z+1`, not `vX.Y+1.0`. Bumping minor is reserved for breaking changes, removed flags, deprecated routes, or multi-feature milestones framed as such (e.g. v1.5 = native iOS client + Apple Health integration). Pre-existing example: a `v1.5.0` cut was rerouted to `v1.4.14` mid-flight when the diff turned out to be bugfix-dominated.
## Tech stack at a glance
| Layer | Tech | Notes |
| ------------------ | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Runtime | Node 22 (Alpine) + pnpm 10.31 | Node version fixed only by the Dockerfile base image; no `.nvmrc` or `engines`. |
| Framework | Next.js 16.2.6 — App Router, `output: "standalone"` | SWC strips `console.*` in prod except `error/warn`. |
| Language | TypeScript 6, `strict: true`, `moduleResolution: "bundler"` | Path alias `@/*` → `./src/*`. `scripts/` excluded from typecheck. |
| UI | React 19 (exact pin), Tailwind 4, shadcn/ui (new-york style, zinc base), Radix, Lucide | Recharts 3 for charts — stays; replacement requires explicit approval. |
| Persistence | PostgreSQL 16 + Prisma 7.8 — 80 models | Generated client at `src/generated/prisma`. Production image ships a separate `/opt/prisma-cli` install + `/opt/pg-boss` runtime install (see `Dockerfile:93-100`). |
| Queue | pg-boss 12.18 | Cron + retry semantics live in Postgres tables; workers under `src/lib/jobs/`. |
| Auth | `@simplewebauthn/server` 13, `@node-rs/argon2` 2 | Passkey + Argon2id password, server-side sessions in Postgres. |
| Notifications | `@parse/node-apn` 8 (APNs), `web-push` 3 (VAPID), raw fetch for Telegram + ntfy | Per-channel `recordPushAttempt` row, hard-reject classification. |
| Forms / validation | `react-hook-form` 7 + `zod` 4 + `@hookform/resolvers` + `zod-openapi` 5 | The Zod registry is the source of truth for `docs/api/openapi.yaml`. |
| Testing | Vitest 4 (unit + integration), Playwright 1.60 (e2e), `@axe-core/playwright` | Integration runs against a `testcontainers/postgresql` Postgres 16. |
| Container | Multi-stage Alpine Dockerfile, GHCR multi-arch (`amd64` + `arm64`) | `pull_policy: always` in `docker-compose.yml` is load-bearing — without it Docker re-uses the stale `:latest` digest. |
More detail in `.planning/codebase/tech.md`, generated as part of the v1.5.2 codebase audit.
## How a request flows
```
client
│ HTTP
▼
src/proxy.ts renamed from middleware.ts. Public-path allowlist,
│ demo-mode mutation block, server-side onboarding
│ redirect, worker-only refusal, request-id + nonce
│ generation, every security header.
▼
src/app/<route>/route.ts every API route wraps in `apiHandler(...)`
│
▼
src/lib/api-handler.ts WideEventBuilder + AsyncLocalStorage, auth resolver
│ (cookie OR Bearer), idempotency, error envelope,
│ GlitchTip forwarder. Cookie path can elevate to
│ admin via requireAdmin(); Bearer never can.
▼
handler body Zod parse (`safeParse` + `returnAllZodIssues`),
│ business logic, `annotate({ action, meta })`,
│ `auditLog()` for sensitive ops.
▼
src/lib/api-response.ts `apiSuccess(data, status?)` → `{ data, error: null }`;
│ `apiError(msg, status, meta?)` threads `meta` on the
│ error path only — `{ data: null, error, meta? }`.
▼
src/proxy.ts security headers attach, request-id echoes.
▼
client
```
The architecture map in `.planning/codebase/arch.md` walks each layer with file:line citations.
## Code conventions
- **UI work follows the design standards.** `.planning/audits/2026-07-05-fable/UI-STANDARDS.md` is the binding design rulebook (card anatomy via TileHeader, spacing scale, text-colour rules — foreground for content, muted for meta only —, token discipline, primitive mapping). Read it before touching any surface; deviations need a written reason in the PR. Where the standards conflict with older in-file comments, the standards win.
- **RSC by default.** Server components are the default; add `"use client"` only when a hook, state, or browser API actually needs it.
- **Every API route wraps in `apiHandler`, with three documented exceptions.** The MCP OAuth routes (`mcp/oauth/{authorize,token,register}`) use `withBackgroundEvent` instead, because OAuth requires RFC-format error bodies that the standard envelope cannot express. They carry the same protections by hand — rate limit, body cap, kill switch, mandatory PKCE-S256, redirect-URI allowlist. Every other route wraps. Every body-accepting route runs Zod `safeParse` and returns 422 via `returnAllZodIssues` (multi-issue envelope sanitised against echoed input through `sanitiseZodIssues`).
- **`userId` is always narrowed from session or Bearer.** No route accepts `userId` as a body field — it comes from `requireAuth()` and feeds the Prisma `where`. The batch endpoints (`/api/measurements/batch`, `/api/mood-entries/bulk`, `/api/medications/intake/bulk`, `/api/workouts/batch`) don't even declare a `userId` field in their Zod schemas.
- **No mass assignment.** Every `prisma.X.{create,update}({ data: ... })` builds its `data` object field-by-field from `parsed.data`, never by spreading the parsed object whole. `tokens.create` builds `permissions` from a hardcoded literal, never from request input.
- **Bearer scope is fail-closed.** `requireAuth()` with no argument admits a cookie session or a wildcard (`["*"]`) token only — a narrow-scope token is refused with 403 and an `undeclared_scope` audit row. A route that means to accept a narrow token names the scope: `requireAuth(FHIR_READ_SCOPE)`. Adding a route therefore cannot silently widen an existing token's reach, because the default arm denies. Only the login / passkey / refresh paths mint `["*"]`; every user-facing mint is narrow, and narrow now means narrow. Two surfaces resolve a Bearer token outside `requireAuth` — `/api/ingest/medication` and `/mcp` — and that set is frozen by a structural test (`src/__tests__/bearer-scope-enforcement-guard.test.ts`).
- **`requireAdmin()` is cookie-only.** Bearer tokens — even with `["*"]` scope — cannot reach admin endpoints. This is a structural boundary in `requireAdmin()` (`src/lib/api-handler.ts`), not a runtime check that future code can soften.
- **Raw SQL is parameter-bound or whitelist-spliced.** Every `$queryRaw` uses tagged-template parameters. Every `$queryRawUnsafe` / `$executeRawUnsafe` either passes positional `$N` placeholders with an argument array OR splices a value already asserted against a closed enum + regex (see the whitelist-splice block in `src/lib/rollups/measurement-rollups.ts`, around the `$queryRawUnsafe` calls). Inline comments document the whitelist at the splice point.
- **TanStack Query keys live in the centralised factory.** `src/lib/query-keys/` (16 modules, `index.ts` barrel) is the only legal source of `queryKey` / `mutationKey` arrays. The in-repo ESLint rule `healthlog/queryKey-factory` (set to `error`) flags any bare array. Same-key + different `queryFn` shape silently poisons the cache; the factory prevents it. Every read unwraps `(await res.json()).data` from the envelope.
- **`annotate()` on every interesting code path.** Wide-event observability. Action names follow `<surface>.<noun>.<verb>` (`coach.budget.exceeded`, `measurement.batch.ingest`). Don't free-text in `meta` — pin the shape so dashboards stay stable.
- **i18n keys exist for every `t()` call.** `i18n-call-site-coverage.test.ts` walks every `.ts(x)` under `src/`, extracts every `t("ns.key")` literal, and asserts each key resolves in `messages/en.json`. `i18n-locale-integrity.test.ts` propagates the EN guarantee across `de / en / es / fr / it / pl`. If a guard fails, fix the bundle, don't suppress the test.
- **OpenAPI registry stays in sync.** Zod schemas carry `.meta()` annotations under `src/lib/openapi/`; `pnpm openapi:generate` emits `docs/api/openapi.yaml`. CI fails on drift. Re-run the generator after touching a request / response schema and commit the YAML alongside the Zod change.
- **File naming.** Components and lib files: kebab-case (`daily-briefing.tsx`, `secure-cookie.ts`). Hooks: `use-` prefix kebab-case (`use-coach-prefs.ts`). React component exports stay PascalCase regardless of filename.
## Tests and commands
- `pnpm typecheck` — `tsc --noEmit`. Required before every commit.
- `pnpm lint` — ESLint flat config with `eslint-config-next` + the project's `healthlog/queryKey-factory` rule. One harmless warning in `withings/resume/__tests__/route.test.ts` is allowed; everything else should be clean.
- `pnpm test` — Vitest 4 unit + component suite. Required before pushing a fix; full suite is fast (~30 s).
- `pnpm test:integration` — Vitest against a `testcontainers/postgresql` Postgres 16. Needs Docker / OrbStack running. Slower but pins the contracts the unit-mocks can't.
- `pnpm e2e` — Playwright against `pnpm exec next build` + Postgres. Heavy; CI runs it, locally only when touching UI flows.
- `pnpm openapi:generate` — regenerates `docs/api/openapi.yaml` from the Zod registry. Run after any request / response schema change. CI's `openapi:check` fails the build on drift.
- `pnpm check-env` — validates that `.env.production.example` covers every required variable. Wired into CI as `env-check.yml`.
- `pnpm bundle-report` / `ANALYZE=1 pnpm build` — bundle inspection. Use when a feature adds material weight.
- One-shot scripts live in `scripts/`. Run them via `pnpm dlx tsx scripts/<file>.ts` — the production standalone image strips `tsx` and the bare `pnpm tsx ...` invocation fails inside the container. Recurring tasks belong on pg-boss, not on a CLI script.
## Critical-files map
| Concern | File | What it owns |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| HTTP edge | `src/proxy.ts` | Public-path allowlist, demo-mode block, onboarding redirect, request-id + CSP nonce, every security header, worker-only refusal. |
| API kit | `src/lib/api-handler.ts` | `apiHandler` wrapper, `requireAuth` / `requireAdmin`, idempotency plumbing, GlitchTip forwarder. |
| Response envelope | `src/lib/api-response.ts` | `apiSuccess` / `apiError`, `safeJson` (now with opt-in `maxBytes`), trusted-proxy IP resolver, `returnAllZodIssues` + `sanitiseZodIssues`. |
| Crypto at rest | `src/lib/crypto.ts` | AES-256-GCM with versioned key ids, `extractKeyId`, fail-closed loader, rotation primitives. Rotation CLI: `scripts/rotate-encryption-key.ts`. |
| Session | `src/lib/auth/session.ts` + `src/lib/auth/secure-cookie.ts` | Postgres-backed sessions, sliding 30-day expiry, `shouldEmitSecureCookie()` the one source of truth for the `Secure` flag (every cookie setter routes through it). |
| Refresh tokens | `src/lib/auth/refresh-token.ts` | Per-device one-time-use rotation with reuse-detection that revokes the device's token family. |
| Bearer tokens | `src/lib/auth/hmac.ts` + `src/lib/auth/issue-token.ts` | HMAC-SHA256 hashing under `API_TOKEN_HMAC_KEY`; no plaintext path; `lastUsedAt` updated fire-and-forget. |
| Rollup tier | `src/lib/rollups/` | DAY / WEEK / MONTH / YEAR pre-aggregations. Read-swap pattern: try the rollup, fall back to live SQL only on coverage miss. Boot-time `rollup-full-backfill` queue handles new accounts. |
| Compliance | `src/lib/analytics/compliance.ts` | Cadence-aware medication compliance — daysOfWeek + intervalWeeks honoured across the eight call sites that surface a rate (Coach prompt, BP-status gate, dashboard pillar, …). |
| AI providers | `src/lib/ai/provider.ts` (`resolveProvider`) + `src/lib/ai/provider-chain.ts` (`PROVIDER_CHAIN_TYPES`) + `src/lib/ai/{openai,anthropic,local,codex,mock}-client.ts` | Five providers, hand-rolled fetch over the documented wire (no vendor SDKs). Mock is excluded structurally by the `PROVIDER_CHAIN_TYPES` allowlist — production cannot reach it. |
| Notification dispatcher | `src/lib/notifications/dispatcher.ts` + `src/lib/notifications/senders/` | APNs → Telegram → ntfy → Web Push cascade, hard-reject classification, `push_attempts` ledger with 90-day retention. |
| Coach | `src/app/api/insights/chat/route.ts` + `src/lib/ai/coach/` | SSE stream, budget gate + per-user rate gate, refusal detector, snapshot builder, message persistence with `encryptedContent` Bytes column. |
| OpenAPI | `src/lib/openapi/registry.ts` + `src/lib/openapi/routes/` (23 modules, `index.ts` barrel) | Source of truth for `docs/api/openapi.yaml`. |
| Schema | `prisma/schema.prisma` (80 models) | `cuid()` PKs, `snake_case` columns via `@map`, encrypted columns mostly under `*Encrypted` (search the file). |
| Compose | `docker-compose.yml` | `app` + `db`. Env-var whitelist under `environment:` — vars not listed never reach the container. `pull_policy: always` is load-bearing. |
`.planning/codebase/arch.md` carries the full annotated walk for every section.
## Security-relevant patterns
The v1.5.2 audit produced `.planning/security-audit-v1.5.2.md` and a stack of per-domain findings under `.planning/security-audit-findings/`. The patterns worth preserving on future churn:
- **Encryption: fail closed everywhere.** Missing keys, malformed JSON, unknown key ids — every path throws rather than silently writing plaintext. Operators rotating keys must keep the legacy entry in `ENCRYPTION_KEYS` until the rotation script reports zero remaining legacy rows. The script covers every `*Encrypted` column + `CoachMessage.encryptedContent` (Bytes) + `IntegrationStatus.lastError`.
- **SSRF: input-time `isPublicUrl` is the floor.** The helper `isPublicUrl()` in `src/lib/validations/notifications.ts` catches every IPv4 / IPv6 alt-notation class (octal / hex / decimal IPv4, IPv4-mapped IPv6, IPv6 ULA, CGNAT, link-local, loopback, metadata). Use it for every outbound URL that comes from a user / admin input. DNS rebinding and the redirect-follow gap remain as architectural follow-ups (issues #217 + #218). Until those land, every NEW outbound `fetch()` that touches a user-controlled host must pin `redirect: "manual"` and an `AbortSignal.timeout`.
- **Outbound egress goes through `safeFetch`.** `src/lib/safe-fetch.ts` is the one documented egress entry (`redirect: "manual"` + `AbortSignal.timeout(15_000)` defaults; opt into the connect-time DNS-rebinding pin with `requirePublicHost: true` for any operator- or user-supplied host). The in-repo ESLint rule `healthlog/safe-fetch-required` (set to `error`) bans raw `fetch(` under `src/lib/` + `src/app/` outside the wrapper internals and test files; same-origin relative-path (`/api/…`) client fetches are exempt by construction.
- **Rate limits live in Postgres.** `src/lib/rate-limit.ts` uses a single atomic SQL upsert returning `{count, reset_at}`. Multi-instance correctness is structural. Bucket-key convention: `<surface>:${identifier}` where identifier is `userId` for authenticated routes and the trusted-proxy IP for anonymous. Anonymous auth surfaces use `checkAuthSurfaceRateLimit(...)` so a trust-violation collapses every anonymous caller into a single tight bucket rather than a free-for-all under the unknown-IP bucket.
- **Webhooks: shared-secret + `timingSafeEqual` + per-source rate limit BEFORE secret verification.** Withings, Telegram, moodLog, the Coolify deploy webhook, and the CSP report endpoint all follow this exact pattern. The Withings path-segment secret is scrubbed from `http.path` / `http.route` by `redactSecrets()` via the `PATH_SECRET_PATHS` registry in `src/lib/logging/redact.ts:40`.
- **Idempotency keys are user-scoped, length-bounded, and refuse to cache secret-shaped responses.** `src/lib/idempotency.ts`. A client cannot replay another user's idempotency key (composite unique index by `userId_key_method_path`). The cache helper refuses to persist a response body containing `hlk_` / `hlr_` / `sk-` patterns even if a future caller wraps an auth endpoint by mistake.
- **CSP: strict, per-request nonce, surgical `connect-src`.** Production CSP gates third-party hosts to the routes that need them — AI hosts only on `/settings/ai/*`, `wbsapi.withings.net` only on `/settings/integrations/withings/*` + `/api/withings/*`. Exactly one `dangerouslySetInnerHTML` in the tree (the theme bootstrap in `src/app/layout.tsx`, nonce-bound, file-literal content). No markdown library installed anywhere; Coach / briefing / insights render as plain React text children. Don't add one without a security review of the renderer's HTML-allow defaults.
- **No CORS headers anywhere.** Same-origin-only by construction. Adding `Access-Control-Allow-Origin: *` anywhere would silently open every authenticated endpoint to cross-origin POST; webhook routes accept cross-origin POSTs but they authenticate by header secret, not cookie.
- **Redaction at the egress boundary, not at the call site.** `redactSecrets()` + `redactSensitiveFields()` + `redactOptional()` are applied centrally in `WideEventBuilder` and `reportToGlitchtip`. Don't add new redactor calls in handler bodies — extend the central denylists in `src/lib/observability/redact-payload.ts` and `src/lib/logging/redact.ts`.
## Self-hosting gotchas
These keep tripping operators; they're worth knowing before you push something that breaks them.
- **`SESSION_COOKIE_SECURE`** controls the `Secure` flag on every cookie the app issues. Unset → flag tracks `NODE_ENV === "production"`. Set to `false` for LAN / Tailscale / VPN-only HTTP self-hosts where the operator deliberately serves plain HTTP. Set to `true` to force the flag even in dev. The compose `environment:` block must list it explicitly — vars not on the whitelist don't reach the container even when `.env` has them. v1.5.2 closed the missing-whitelist regression.
- **`pull_policy: always`** in `docker-compose.yml` is load-bearing. Without it Docker keeps a stale `:latest` digest cached and silently skips the registry round-trip on `compose up`.
- **`ENCRYPTION_KEYS` map vs legacy `ENCRYPTION_KEY`.** The map (`{"v1":"<hex>","v2":"<hex>"}` + `ENCRYPTION_ACTIVE_KEY_ID=v2`) is the modern path. The legacy single-key env var stays accepted as `v1`. Rotation playbook lives at `docs/ops/encryption-key-rotation.md`; do NOT drop a key from the map before the rotation script reports zero remaining rows on that key.
- **GeoLite2 databases.** Operators run `scripts/fetch-geolite2.sh` with a MaxMind licence key before `docker build`; the image bakes the MMDBs into `/opt/geolite2/`. Without them the resolver falls back to online `ipwho.is` and the admin login overview surfaces "unknown" for offline lookups.
- **APNs `.p8` key.** v1.4.47.x established `APNS_KEY_B64` as the env-var contract — pasting the PEM body verbatim through Coolify's `env_file` mangled the literal newlines. The key must be scoped "Sandbox & Production" in the Apple Developer Portal; a Sandbox-only key fails with `BadEnvironmentKeyInToken` on production builds.
## iOS handoff
The native SwiftUI client lives in a separate repository and rides public beta via TestFlight. Backend contract the iOS app speaks against is locked in `docs/api/openapi.yaml` and has been continuously validated since v1.4.23. Coordination notes worth knowing on the backend side:
- `POST /api/measurements/batch` recognises `externalId` values starting with `stats:` (e.g. `stats:HKQuantityTypeIdentifierStepCount:YYYY-MM-DD`) as **overwrites** — a re-post replaces the row's value / unit / measuredAt / externalSourceVersion / deviceType / sleepStage. Every other externalId prefix (`uuid-*`, opaque HK identifiers) keeps the first-write-wins immutable contract because each sample is a canonical reading. Per-entry status field carries `"updated"` for an overwrite vs `"inserted"` for a fresh row.
- Refresh-token rotation is per-device; reuse-detection revokes the family scoped to that device.
- `requireAdmin()` is cookie-only by construction — a Bearer token, even with `["*"]` scope, cannot reach `/api/admin/*`. The iOS app is not an admin client.
- The `clientManaged` opt-in on `PATCH /api/auth/me/notification-prefs` suppresses server-side `MEDICATION_REMINDER` APNs for clients that manage their own local reminders. The cron skips dose-due APNs when the flag is `true` and emits a `medication_reminder.suppressed_client_managed` wide-event annotation per skip.
- `push_attempts` table holds the last 90 days of APNS / Web-Push / Telegram / NTFY delivery attempts (channel + eventType + result + reason + createdAt). The admin diagnostic endpoint at `/api/admin/notifications/diagnostic` surfaces masked device tokens + channel state + last 20 attempts for the calling user.
- Any request / response schema change must regen `docs/api/openapi.yaml` and commit the result. CI fails on drift.
- The iOS app signs in via the native OIDC SSO handoff **or** password/passkey. The native SSO leg: the app opens `GET /api/auth/oidc/login?client=native&code_challenge=<S256>` in an `ASWebAuthenticationSession`, the callback returns to the fixed compiled-in scheme `healthlog://oidc-callback` with a one-time `code=hlh_…` (or an `mfa_ticket` when the account has a second factor), and the app exchanges the code at `POST /api/auth/oidc/native/token` for the standard native bundle (`src/lib/auth/oidc-native-handoff.ts`). The `healthlog://` scheme is a compile-time constant — no `redirect_uri` param, no env allowlist (the open-redirect class is removed by construction). The token pair NEVER rides a URL; only the opaque handoff code / MFA ticket do. The SSO login can never satisfy `requireFreshMfa` step-up — the native transport is Bearer and `requireFreshMfa` is cookie-only, and even the web path sets `mfaVerifiedAt` only from the app's own factor. `OIDC_ONLY=true` is now supported for iOS but requires the app build that ships this flow; an older password/passkey-only build is locked out. The MFA-verify and `/api/auth/refresh` routes that complete/rotate an OIDC login are deliberately not OIDC-only-blocked.
## DO-NOTs
- **No `--no-verify` on commits.** If a pre-commit hook fails, fix the cause; never bypass.
- **No `--no-gpg-sign` unless explicitly asked.**
- **No force-push to `main`** (or to release branches, by extension).
- **No destructive git operations as shortcuts.** `git reset --hard`, `git clean -fd`, `git checkout .` etc. only when the maintainer asked or the state is unambiguously local-only.
- **No assistant / planning vocabulary in any committed artefact** — see Voice and privacy above.
- **No personal names** — the maintainer's, a reporter's, or anyone else's — in any committed artefact, including code comments referring to "the user who reported issue #X".
- **No secret-shaped strings in `.env.example` or git history.** Pre-existing scan shows clean.
- **No markdown library in dependencies.** Coach + briefing + insights render as React text children for XSS reasons; adding one is a security decision, not a UX one.
- **No `Access-Control-Allow-Origin` headers** anywhere except the documented webhook surfaces — and those authenticate via header secret, not cookie.
- **No `pnpm tsx scripts/foo.ts` in production.** The standalone image strips `tsx`. One-shot scripts run via `pnpm dlx tsx`; recurring tasks belong on pg-boss.
- **No backwards-compatibility shims for hypothetical callers.** When a change is clean, make it cleanly; do not leave dead branches "in case" something we don't ship needs them.
## Pointers
| What | Where |
| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| Public face | `README.md` |
| Release history | `CHANGELOG.md` |
| Codebase audit (v1.5.2) | `.planning/codebase/{tech,arch,quality,concerns}.md` |
| Security audit (v1.5.2) | `.planning/security-audit-v1.5.2.md` + `.planning/security-audit-findings/*` |
| Operator runbooks | `docs/ops/` (deploy, encryption-key-rotation, env-check, backup-restore, migrations) |
| Self-hosting guide | `docs/self-hosting/` |
| Public API contract | `docs/api/openapi.yaml` |
| Architecture diagrams | `docs/diagrams/` (rendered through `docs.healthlog.dev`) |
| iOS coordination notes | `.planning/ios-coord/` |
| Maintainer working notes | `.planning/PROJECT.md`, `.planning/ROADMAP.md`, `.planning/STATE.md` (gitignored release scratch lives alongside; the patterns are in `.gitignore`) |
| Personal preferences + collaboration memory | `~/.claude/projects/-Users-marc-Projects-HealthLog/memory/MEMORY.md` (local to the maintainer's working copy; not in the repo) |
## Agent context hygiene (ECONNRESET / oversized-request prevention)
- **NEVER read `src/generated/**`** — Prisma-generated client (~9 MB, 83 files incl. a 224 KB+ inline schema). Reading/grepping it explodes the request context → 10-min "cogitations" + `ECONNRESET`on large turns. Regenerate via`pnpm prisma generate`if needed; never open it. (Enforced via`.claude/settings.local.json` Read-deny.)
- Avoid bulk-reading other large artifacts: `pnpm-lock.yaml`, `CHANGELOG.md`, `messages/*.json`, `docs/api/openapi.yaml` — grep narrowly instead of full reads.
- For very large multi-agent actions: cap concurrent sub-agents (many simultaneous streaming requests + big context is the worst combo for connection resets). When on a VPN (Tailscale MTU 1280 / iCloud Private Relay), large requests can hit MTU black-holes — disable the tunnel for heavy sessions.
+1 -1
View File
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project
HealthLog — a personal health-tracking PWA (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, moodLog.app sync, Dracula-themed UI, mobile-first design, offline caching, and doctor report PDF export.
HealthLog — a personal health-tracking PWA (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, Dracula-themed UI, mobile-first design, offline caching, and doctor report PDF export.
## Commands
+4 -1
View File
@@ -43,7 +43,10 @@ RUN pnpm db:generate
# Build Next.js
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
# Keep the larger V8 heap build-only. Next's repository-wide TypeScript
# worker exceeds the default ~4 GiB old-space ceiling; the runner stage below
# starts fresh and therefore does not inherit this setting.
RUN NODE_OPTIONS=--max-old-space-size=8192 pnpm build
# ── Stage 3: Production runner ─────────────────────────────
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS runner
+2 -2
View File
@@ -1,11 +1,11 @@
{
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include the shared rootMainFiles baseline. Measured at v1.32.0 after the correctness/UX architecture wave: / 425, /insights 411, /measurements 412, /insights/mood 428, total 2982, baseline 129. Route caps retain focused headroom; the aggregate cap allows normal chunk noise while still catching a new ~90 KB duplicated library. Message catalogs and duplicate Recharts chunks have dedicated structural guards.",
"$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.34.1: / 431, /insights 420, /measurements 417, /insights/mood 438, total 3238, baseline 130, lazy catalogs 792. The 108 KB aggregate rise from v1.34.0's 3130 is not one user load: the explicit assessment error/timeout/poll-exhaustion contract adds 2.7-4.4 KB to each of roughly forty mutually exclusive metric route chunks, which the whole-build sum counts independently. Catalogs account for only about 2 KB of the delta, no catalog is statically route-referenced, and Recharts remains exactly one fingerprinted chunk. A measured single next/dynamic assessment boundary was reverted because it raised the artifact further to 3243 KB while saving only about 1 KB per route. The aggregate cap therefore moves narrowly from 3140 to 3260 (22 KB headroom); per-route ceilings, the static-catalog guard, and the one-Recharts-chunk guard remain unchanged.",
"routesKbGz": {
"/page": 460,
"/insights/page": 445,
"/measurements/page": 445,
"/insights/mood/page": 460
},
"totalClientKbGz": 3050,
"totalClientKbGz": 3260,
"maxRechartsChunks": 1
}
+23 -2
View File
@@ -15,8 +15,10 @@
# Leave it unset (or true) only when a TLS reverse proxy serves HTTPS.
#
# Persistent state lives in exactly one named volume (`pgdata`, Postgres'
# data dir). The app container is stateless — GeoLite2 MMDBs are baked into
# the image — so a backup of the database is a backup of everything.
# data dir). The app container is stateless, so a backup of the database is
# a backup of everything. The optional GeoLite2 databases are either baked
# into the image at build time or mounted read-only from the host and
# pointed at with GEOLITE2_DIR — either way they are replaceable, not state.
services:
# Single-container default (HEALTHLOG_PROCESS_TYPE=all). Existing
# self-host deployments stay working with `docker compose up`.
@@ -96,6 +98,15 @@ services:
IP_GEO_LOOKUP_URL: "${IP_GEO_LOOKUP_URL:-}"
IP_GEO_LOOKUP_DISABLED: "${IP_GEO_LOOKUP_DISABLED:-}"
IP_GEO_ALLOW_INSECURE: "${IP_GEO_ALLOW_INSECURE:-}"
# Directory the offline GeoLite2 databases are read from. Default
# `/opt/geolite2` — the path the published image bakes its own copy
# into, if the build had a MaxMind licence key. Point this at a
# directory you mount yourself (see the `volumes:` block below) to
# bring your own GeoLite2-City.mmdb + GeoLite2-ASN.mmdb, refreshed
# with `geoipupdate` on your own schedule. Listed here so an operator
# value in .env reaches the container (the `environment:` block is a
# whitelist). See docs/self-hosting/geolite2.md.
GEOLITE2_DIR: "${GEOLITE2_DIR:-/opt/geolite2}"
# all = bundle web + worker in this container (default, single-host)
# web = serve HTTP only; pair with a separate app-worker service
HEALTHLOG_PROCESS_TYPE: "${HEALTHLOG_PROCESS_TYPE:-all}"
@@ -219,6 +230,16 @@ services:
# Set "true" only if your own iOS build carries Apple's Critical Alerts
# entitlement; otherwise urgent alerts use the time-sensitive level.
APNS_CRITICAL_ENTITLEMENT: "${APNS_CRITICAL_ENTITLEMENT:-}"
# Bring your own GeoLite2 databases. The published image only carries
# MMDBs if the build had a MaxMind licence key, so on `:latest` the
# offline tier is usually absent and every login IP goes to the online
# provider instead. Create a free MaxMind account, pull
# GeoLite2-City.mmdb + GeoLite2-ASN.mmdb with `geoipupdate`, mount the
# directory read-only, and set GEOLITE2_DIR to the container path.
# Steps: docs/self-hosting/geolite2.md.
#
# volumes:
# - ./geolite2:/var/lib/geolite2:ro
depends_on:
db:
condition: service_healthy
+4 -1
View File
@@ -23,7 +23,10 @@ notes, and machine-readable specs that ship alongside the source:
off-by-default MCP connector and pointing Claude.ai / ChatGPT /
Claude Desktop at it, and [`self-hosting/sso.md`](./self-hosting/sso.md)
— OIDC SSO login: IdP setup, the identity-pinning security model,
`OIDC_ONLY` consequences, and the break-glass runbook.
`OIDC_ONLY` consequences, and the break-glass runbook. Also
[`self-hosting/geolite2.md`](./self-hosting/geolite2.md) — bringing your
own offline GeoLite2 databases via `GEOLITE2_DIR` instead of sending
login IPs to a third-party lookup.
- [`migration/`](./migration/) — release-by-release migration notes.
Read the entry for the version you're upgrading from.
- [`audit/`](./audit/) — per-release audit summaries archived for
+1486 -293
View File
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 620" font-family="ui-sans-serif, -apple-system, Segoe UI, Roboto, sans-serif" role="img" aria-labelledby="title desc">
<title id="title">HealthLog data flow — device to insight</title>
<desc id="desc">Five-stage pipeline: sources (Withings, Apple Health export, iPhone HealthKit, manual entry, moodLog.app) feed ingest endpoints, which write to Postgres Measurement and MeasurementRollup tables, then surface on Dashboard tiles, Insights cards, the AI Coach, and the Doctor PDF.</desc>
<desc id="desc">Five-stage pipeline: sources (Withings, Apple Health export, iPhone HealthKit, manual entry, Nightscout) feed ingest endpoints, which write to Postgres Measurement and MeasurementRollup tables, then surface on Dashboard tiles, Insights cards, the AI Coach, and the Doctor PDF.</desc>
<!-- Dracula background -->
<rect x="0" y="0" width="1200" height="620" fill="#282a36" />
@@ -45,8 +45,8 @@
<text x="120" y="411" text-anchor="middle" fill="#bd93f9" font-size="11">PWA · iOS Shortcuts</text>
<rect x="30" y="444" width="180" height="56" rx="10" fill="#44475a" stroke="#8be9fd" stroke-width="1.5" />
<text x="120" y="469" text-anchor="middle" font-weight="600">moodLog.app</text>
<text x="120" y="487" text-anchor="middle" fill="#bd93f9" font-size="11">webhook</text>
<text x="120" y="469" text-anchor="middle" font-weight="600">Nightscout</text>
<text x="120" y="487" text-anchor="middle" fill="#bd93f9" font-size="11">pull sync</text>
</g>
<!-- INGEST (orange #ffb86c) -->

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+1 -1
View File
@@ -81,7 +81,7 @@
<rect x="1010" y="240" width="160" height="100" rx="10" fill="#44475a" stroke="#8be9fd" stroke-width="1.5" stroke-dasharray="4 4" />
<text x="1090" y="266" text-anchor="middle" fill="#f8f8f2" font-size="13" font-weight="600">Withings</text>
<text x="1090" y="282" text-anchor="middle" fill="#6272a4" font-size="11">Apple Health</text>
<text x="1090" y="298" text-anchor="middle" fill="#6272a4" font-size="11">moodLog.app</text>
<text x="1090" y="298" text-anchor="middle" fill="#6272a4" font-size="11">Nightscout</text>
<text x="1090" y="314" text-anchor="middle" fill="#6272a4" font-size="11">AI provider</text>
<text x="1090" y="332" text-anchor="middle" fill="#bd93f9" font-size="11">(per-user)</text>
</g>

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

+1 -1
View File
@@ -8,7 +8,7 @@ copy.
| File | Story it tells |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `01-data-flow.svg` | Sources (Withings, Apple Health export.zip, iPhone HealthKit, manual, moodLog) → ingest endpoints → Measurement + MeasurementRollup → reads (rollup probe / live SQL fallback / cache) → surfaces (dashboard, insights, Coach, doctor PDF). |
| `01-data-flow.svg` | Sources (Withings, Apple Health export.zip, iPhone HealthKit, manual, Nightscout) → ingest endpoints → Measurement + MeasurementRollup → reads (rollup probe / live SQL fallback / cache) → surfaces (dashboard, insights, Coach, doctor PDF). |
| `02-coach-pipeline.svg` | User question → snapshot builder → prompt assembler → provider chain (ChatGPT OAuth / OpenAI / Anthropic / local / admin-shared) → response parser → cited reply with mini-charts. |
| `03-self-hosting-topology.svg` | Internet → reverse proxy (TLS) → Next.js app (port 3000) ↔ pg-boss worker ↔ Postgres 16, plus GHCR image pull, optional Coolify autodeploy, optional S3 off-host backup. |
| `04-source-priority.svg` | Three sources for the same metric on the same day → resolver picks one canonical row per (cumulative vs point) ladder. Losing rows stay in the audit trail. |
+58 -20
View File
@@ -9,14 +9,15 @@ Ollama endpoint can fall back from one to the other automatically.
## Provider matrix
| Provider | Key acquisition | Default model | Endpoint | Privacy stance |
| --------------- | --------------------------------------------- | ------------------- | ----------------------------------------- | ------------------------------------- |
| `OPENAI` | <https://platform.openai.com/api-keys> | `gpt-4o` | `https://api.openai.com/v1` | Measurement context sent to OpenAI |
| `ANTHROPIC` | <https://console.anthropic.com/settings/keys> | `claude-sonnet-4-6` | Anthropic SDK default | Measurement context sent to Anthropic |
| `LOCAL` | None (your endpoint) | `local-model` | OpenAI-compatible URL you control | Stays on your network |
| `CHATGPT_OAUTH` | Sign in with your ChatGPT account | Codex-routed | `chatgpt.com/backend-api/codex/responses` | Routed via your ChatGPT subscription |
| Provider | Key acquisition | Default model | Endpoint | Privacy stance |
| ------------------- | --------------------------------------------- | ------------------- | ----------------------------------------- | --------------------------------------- |
| `OPENAI` | <https://platform.openai.com/api-keys> | `gpt-4o` | `https://api.openai.com/v1` | Measurement context sent to OpenAI |
| `ANTHROPIC` | <https://console.anthropic.com/settings/keys> | `claude-sonnet-4-6` | Anthropic SDK default | Measurement context sent to Anthropic |
| `LOCAL` | None (your endpoint) | `local-model` | OpenAI-compatible URL you control | Stays on your network |
| `OPENAI_COMPATIBLE` | Your gateway's key, or none | none (you name it) | OpenAI-compatible URL you configure | Reaches whatever your gateway routes to |
| `CHATGPT_OAUTH` | Sign in with your ChatGPT account | Codex-routed | `chatgpt.com/backend-api/codex/responses` | Routed via your ChatGPT subscription |
The four spellings above are the canonical `User.aiProvider` enum
The five spellings above are the canonical `User.aiProvider` enum
values. Anything else falls through to the admin-shared OpenAI key
(if configured) or the `NoProvider` stub that surfaces "No AI
provider configured" to the UI.
@@ -54,6 +55,11 @@ order. The default is encoded in
4. `local` — self-hosted Ollama / LM Studio / vLLM endpoint.
5. `admin-openai` — operator's shared key (last-ditch).
`openai-compatible` is a sixth chain entry you can add in the editor.
It is deliberately not in the default chain: it resolves only against a
base URL you saved, so a default entry would be dead weight for
everyone who never configures a gateway.
Entries the user has no credential for are silently skipped. The
runner walks the surviving entries in order; the first to return a
non-error completion wins. A 422 fires only when every entry in the
@@ -150,34 +156,59 @@ internal endpoint.
16-24 GB GPU / M-series Pro/Max (sweet spot for BYOK-style usage),
70B+ for 48 GB+ / multi-GPU (comparable to mid-tier hosted models).
### OpenAI-compatible gateways (LiteLLM, OpenRouter, …)
## OpenAI-compatible gateways (LiteLLM, OpenRouter, vLLM, …)
The **Local** provider is also the gateway path: any service that
speaks the OpenAI `/v1/chat/completions` wire plugs in the same way,
whether it runs on your host or not. The OPENAI provider deliberately
pins `api.openai.com` (an OpenAI key is never forwarded to a custom
host), so a gateway always goes through **Local**:
Since v1.33.1 a gateway has its own provider: **OpenAI-compatible
gateway**. Any service that speaks the OpenAI `/v1/chat/completions`
wire plugs in there, whether it runs on your network or not.
1. In `/settings/ai`, set provider to **Local (OpenAI-compatible)**.
1. In `/settings/ai`, set provider to **OpenAI-compatible gateway**.
2. Base URL is the gateway's OpenAI-compatible root and **ends with
`/v1`** — e.g. `https://litellm.example.com/v1` or
`https://openrouter.ai/api/v1`.
`/v1`** — e.g. `https://litellm.example.com/v1`,
`https://openrouter.ai/api/v1`, or `http://vllm.lan:8000/v1`.
3. API key is **optional** — set it when the gateway requires a
Bearer token (OpenRouter key, LiteLLM master key), leave it blank
otherwise. It is encrypted at rest like every other credential.
otherwise. It is encrypted at rest like every other credential,
in its own column.
4. Model is whatever the gateway routes — e.g.
`anthropic/claude-sonnet-4-6` on OpenRouter or a LiteLLM alias.
Leave it blank and the model saved for your other providers is
used; with neither set the provider is skipped.
Then add **OpenAI-compatible gateway** to the fallback chain (it is
not in the default one) and put it where you want it tried.
**Why it is a separate provider and not a base-URL field on OPENAI.**
The OPENAI provider pins `api.openai.com`, and the gateway provider
reads three columns of its own: its base URL, its own key, its own
model. Neither can see the other's. A key you issued to OpenAI can
therefore never be sent to a host you typed into a different form,
and that property is structural rather than a comment — the OPENAI
arm has no code path that reads the gateway's base URL.
**SSRF guard.** The gateway's base URL passes the same floor as the
Local provider's: a public host always, a private one only when the
operator allowlisted it via `ALLOW_LOCAL_AI_PRIVATE_HOSTS` (documented
under Local endpoints above). LAN gateways are the normal case for LiteLLM and vLLM, so
the escape hatch matters here as much as it does for Ollama.
JSON surfaces send the standard
`response_format: { type: "json_object" }`; an endpoint that rejects
the field is detected on the first refusal and retried without it,
so strict gateways and older local servers both work unmodified.
**The Local provider still works for this** and nothing about it
changed — it speaks the same wire and honours the same base URL. Pick
the gateway provider when the endpoint is a gateway (it says so in
the UI, it keeps a separate key, and it can sit in the chain
alongside Local), and pick Local for a model running on your own
machine.
The **admin** server-key path has a separate guard: an admin-set
custom base URL must additionally be allowed via
`ADMIN_AI_BASE_URL_ALLOWLIST` in the container environment. The
user-level Local provider does not read that variable — it is
governed by the SSRF guard above.
`ADMIN_AI_BASE_URL_ALLOWLIST` in the container environment. Neither
the user-level Local provider nor the gateway provider reads that
variable — both are governed by the SSRF guard above.
## ChatGPT OAuth (Codex)
@@ -256,6 +287,13 @@ provider instead; their own entries always resolve first.
- **LOCAL** keeps everything on your network. Snapshots reach only
the endpoint you configured. With Ollama on the same host as
HealthLog, nothing leaves the host at all.
- **OPENAI_COMPATIBLE** reaches exactly the endpoint you configured,
and then wherever that endpoint routes. A gateway on your own
network keeps the data there; a hosted gateway forwards it to
whichever provider it fronts, under that provider's retention
policy. HealthLog cannot see past the gateway, so the honest
statement is the one above: this provider's privacy stance is your
gateway's.
- **CHATGPT_OAUTH** routes via OpenAI's Codex endpoint. Same caveat
as OpenAI; the difference is the billing axis (ChatGPT
subscription instead of API tokens).
+68 -9
View File
@@ -30,8 +30,10 @@ re-uploading the same archive merges instead of duplicating.
The archive contains `apple_health_export/export.xml` (the canonical
record stream) plus a handful of auxiliary files (workout routes,
clinical records, electrocardiograms). The importer reads
`export.xml` and ignores the rest.
clinical records, electrocardiograms). The importer reads `export.xml`
and supported ECG CSVs under
`apple_health_export/electrocardiograms/`; unsupported auxiliary files
remain ignored.
## 2. Upload to HealthLog
@@ -76,7 +78,15 @@ canonical envelope:
"uploadBytes": 412345678,
"exportedAt": "2026-04-30T18:12:00.000Z",
"progress": { "records_seen": 124000, "workouts_seen": 230 },
"result": null,
"result": {
"ecg": {
"discovered": 2,
"imported": 1,
"updated": 0,
"skipped": 0,
"failed": 1
}
},
"failureReason": null
},
"error": null
@@ -94,8 +104,8 @@ they triggered.
## What gets imported
The parser folds four XML element types into the existing HealthLog
schema (CHANGELOG v1.4.34):
The parser folds four XML element types and supported auxiliary ECG
CSVs into the existing HealthLog schema (CHANGELOG v1.4.34):
| XML element | Maps to | Notes |
| ------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -103,11 +113,45 @@ schema (CHANGELOG v1.4.34):
| `<Workout>` | `Workout` rows | Activity type + duration + energy + distance. |
| `<Correlation>` | `Measurement` rows | Compound samples like blood pressure (systolic + diastolic) explode into their per-metric children. |
| `<ClinicalRecord>` | `Measurement` rows where the type maps | Health-record FHIR snapshots; only the metric-typed fields are ingested. |
| ECG CSV | `EcgRecording` rows | The source-provided waveform is encrypted before persistence; descriptors and the device's classification are stored without diagnostic inference. |
The HK quantity types that map to HealthLog metric types live in
`src/lib/measurements/apple-health-mapping.ts`. Anything outside the
mapped set (workout routes, ECG voltage traces) is counted in
`progress.records_skipped` but not stored.
mapped set (for example workout routes) is counted as unsupported and
not stored.
### ECG archive safety, privacy, and idempotency
ECG support is source-agnostic: if HealthKit includes a valid
`HKElectrocardiogram` CSV, HealthLog can import it regardless of which
compatible device originally wrote it. Archive member names are never
used as filesystem paths and ECGs are never extracted to plaintext
temporary files.
The auxiliary reader enforces limits before and during decompression:
- at most 20,000 total archive members and 2,000 ECG CSV members;
- at most 16 MiB per decompressed ECG CSV and 512 MiB across all ECG CSVs;
- at most a 200:1 declared compression ratio; and
- at most 2,000,000 waveform samples per recording.
Encrypted ZIP members, unsupported compression, unsafe or duplicate
paths, forged size metadata, malformed timestamps or samples, and
limit overruns are rejected. An ECG failure is fail-soft: valid
`export.xml` measurements and workouts still commit, while the
terminal result reports only
`discovered/imported/updated/skipped/failed` ECG counts. It never
returns filenames, source labels, sample values, or raw parser errors.
Waveforms are converted to integer microvolts and encrypted with the
existing AES-256-GCM ECG codec before the transaction writes an
`EcgRecording` with `source=APPLE_HEALTH`. Stable identity is derived
from normalized recording content rather than the CSV filename, so a
renamed re-export updates the same user-scoped row while distinct
recordings and different users remain independent. Known Apple
device classifications are preserved as source-reported facts;
unknown or localized classifications are stored as `null`. HealthLog
does not inspect the waveform to infer AFib or make a diagnosis.
### Cumulative HK type → daily aggregate
@@ -148,8 +192,8 @@ The two-axis idempotency story:
1. **File-level** — re-uploading the same `export.zip` (same SHA-256
of bytes) short-circuits to the previous job only when that job
used the current parser revision. Existing jobs are revision 1;
new jobs use revision 2, so a pre-fix successful archive can be
used the current parser revision. Existing jobs may use revisions 1
or 2; new jobs use revision 3, so an earlier successful archive can be
deliberately processed again under the corrected aggregation.
2. **Record-level** — when an older `export.zip` is re-exported from
iOS (same history, slightly newer device timestamps), every
@@ -162,6 +206,21 @@ sees fresh data merged in without duplicates. The job result line
in the status response reports `recordsInserted` and
`recordsUpdated` separately.
### WHOOP records and current export limits
Supported measurements written into HealthKit by another app are
handled by HealthKit type, not by a raw writer label. For example, a
supported heart-rate record written by WHOOP is imported as an
Apple Health measurement and participates in the normal source
priority rules.
WHOOP currently exports neither ECG recordings nor Blood Pressure
Insights to Apple Health. Those two WHOOP data classes therefore
cannot arrive through an Apple Health `export.zip`; a WHOOP writer
label on some other HealthKit record is not evidence of a WHOOP ECG
export. HealthLog's direct WHOOP OAuth/API integration remains
supported for the WHOOP data the API exposes.
## Admin variant — import on behalf of another user
`POST /api/admin/import-apple-health-export` is the cookie-only admin
+72 -3
View File
@@ -1,6 +1,6 @@
# Data import
HealthLog has two ways to bring data in, both reachable from
HealthLog has three ways to bring data in, all reachable from
**Settings → Export & Import**:
1. An **Apple Health `export.zip`** upload — the full archive from the
@@ -10,10 +10,18 @@ HealthLog has two ways to bring data in, both reachable from
2. A **generic JSON import** — a small, explicit payload of measurements
and mood entries, useful for restoring a partial export or migrating
from another tracker. This page documents that format.
3. A **CSV import** — one measurement per row, for a spreadsheet or a
meter / sensor export. Documented in section 3 below.
Both paths skip rows that already exist, so re-running an import is
All three paths skip rows that already exist, so re-running an import is
safe — it merges rather than duplicating.
Every import reports what it actually wrote. A file where nothing was
written is shown as a failure, a file where some rows landed and some
were refused is shown as a partial result, and the refused rows are
grouped by reason so one bad column reads as one line rather than a
scroll.
## 1. Apple Health `export.zip`
On the iPhone: **Health app → profile picture → Export All Health
@@ -56,7 +64,28 @@ skipped and counted under `skipped` in the response.
| `measuredAt` | yes | string | ISO-8601 datetime, e.g. `2026-05-01T08:00:00.000Z`. |
| `source` | no | string | Free-text origin label. Imported rows are tagged `IMPORT` server-side regardless. |
| `notes` | no | string | Optional free text. |
| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE` (e.g. fasting / post-meal). |
| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE`. See [Glucose context](#glucose-context). |
### Glucose context
`glucoseContext` is one of `FASTING`, `POSTPRANDIAL`, `RANDOM`,
`BEDTIME`, and it is genuinely optional on a `BLOOD_GLUCOSE` row. Leave
it out (JSON) or leave the cell blank (CSV) and the reading is stored
with no context.
That is the normal case for a continuous sensor or meter export, which
records a value and a timestamp per reading and classifies nothing. A
reading with no context still counts everywhere a reading counts: the
chart, the all-time summary, and the trailing-30-day clinical panel
(time in range, GMI, CV%, estimated A1c) are all context-agnostic. It is
left out only of the per-context breakdowns — the doctor report's
fasting / post-meal rows, the matching FHIR observations, the per-context
targets — because no classification was recorded and none is invented.
Setting the column on any type other than `BLOOD_GLUCOSE` is rejected.
Setting it to a value outside the four above is rejected. Entering a
reading by hand in the app still asks for a context, because at that
moment you know it.
### Mood entry
@@ -195,8 +224,48 @@ The response is a count envelope:
}
```
## 3. CSV import
`POST /api/import/csv` takes the raw file as `text/csv` — one measurement
per row, no JSON wrapper. The **CSV file** control in
**Settings → Export & Import** posts to it, and **Download example**
mints a file with the right header.
A header row is required; column order does not matter and header
matching is case-insensitive.
| Column | Required | Notes |
| ---------------- | -------- | --------------------------------------------------------------------------------------------------------- |
| `type` | yes | A `MeasurementType` from the table above. |
| `value` | yes | A number, in the row's `unit`. |
| `unit` | yes | The canonical unit, or a recognised alias: `mmol/L` → `mg/dL`, `lb` → `kg`, `in` → `cm`, `°F` → `°C`. |
| `measuredAt` | yes | ISO-8601 **with an explicit offset** (`Z` or `±HH:MM`, and `±HHMM` is accepted). No offset, no import. |
| `glucoseContext` | no | Only for `BLOOD_GLUCOSE`; blank means no context. See [Glucose context](#glucose-context). |
| `notes` | no | Free text, up to 200 characters. |
| `externalId` | no | A source-stable id. With it, a re-upload updates the same row; without it, a re-upload creates a new one. |
A sensor export therefore needs no glucose column at all:
```csv
type,value,unit,measuredAt,glucoseContext,notes,externalId
BLOOD_GLUCOSE,5.3,mmol/L,2024-04-03T13:15:00+1100,,,sensor-1
BLOOD_GLUCOSE,5.9,mmol/L,2024-04-03T13:30:00+1100,,,sensor-2
```
Limits: 16 MB per upload, 10 000 valid rows per import, 5 imports per
hour (shared with the JSON import). `?dryRun=1` validates and previews
without writing anything — the **Preview** button uses it.
The response carries per-row outcomes alongside the counts, and the card
renders the counts, not the status code: rows written and none skipped is
a success, a mixed result is a warning, and nothing written is a failure
with the reasons grouped by count.
## Converting a CSV into the import JSON
The CSV import above covers measurements. Mood entries have no CSV
route, so a mood history still goes through the JSON payload.
A spreadsheet export is the most common starting point. The shape is
small enough to convert by hand for a few rows, or with a one-off script
for many. The steps are the same either way:
+85
View File
@@ -0,0 +1,85 @@
# Nightscout integration
HealthLog can pull continuous-glucose readings from a Nightscout instance.
Public Nightscout origins work without an allowlist. Access to a private or
local destination is an operator decision and is denied unless its exact
origin is listed in `NIGHTSCOUT_PRIVATE_ORIGINS`.
## Security change for upgrades
`NIGHTSCOUT_PRIVATE_ORIGINS` is a breaking security boundary. The old
per-user “allow private host” opt-in is compatibility metadata only: it no
longer grants private-network access. Existing private connections that are
not operator-approved stop syncing with the stable
`private_origin_not_approved` reason until the operator configures the exact
origin.
Set a comma-separated list of complete origins:
```env
NIGHTSCOUT_PRIVATE_ORIGINS="https://cgm.internal.example:443,http://10.20.30.40:1337"
```
Each entry is an exact normalized `scheme://host:port` trust unit:
- only `http` and `https` are accepted;
- scheme, hostname, and effective port must all match;
- paths, query strings, fragments, embedded credentials, and wildcards are
rejected;
- approving one hostname does not approve subdomains, sibling hosts, or a
different port; and
- any malformed non-empty entry fails evaluation instead of silently
weakening the configured policy.
Do not put a Nightscout API token, capability URL, health data, or a URL
containing credentials in the allowlist. Restart the application after
changing the environment.
## Connect safely
Open **Settings → Integrations → Nightscout** and enter the instance base URL
and, when required, its Nightscout API token. HealthLog stores the connection
credentials encrypted. A public, read-only Nightscout instance can omit the
token.
For a private instance, configure the operator allowlist first and enter the
same origin in Settings. For example, if the allowlist contains
`https://cgm.internal.example:443`, these are not equivalent and remain
blocked:
```text
http://cgm.internal.example:443
https://cgm.internal.example:8443
https://other.cgm.internal.example:443
```
The user-controlled private-host checkbox or request field cannot override
this decision.
## DNS pinning and redirects
Every outbound request re-evaluates the stored origin against current server
policy. For public destinations, HealthLog resolves the hostname, rejects
private and embedded-private IPv4/IPv6 answers, and pins the approved address
for the connection so DNS cannot change between validation and use. Redirects
are validated and pinned again; a public URL cannot redirect into the local
network.
An exact private origin is intentionally allowed to resolve privately only
because the operator named that complete origin. Removing it from
`NIGHTSCOUT_PRIVATE_ORIGINS` takes authority away on the next fetch even if an
older user preference remains stored.
## Upgrade checklist
1. Inventory private Nightscout connections without copying their tokens into
tickets or logs.
2. Add only the exact origins that HealthLog must reach.
3. Restart HealthLog so the environment is reloaded.
4. Test the connection from Settings and confirm a sync succeeds.
5. Remove obsolete origins promptly; do not use a broad DNS suffix or wildcard
as a substitute for explicit entries.
If a previously working private connection reports that operator action is
required, compare its exact scheme, host, and port with the allowlist. Do not
restore access by changing the user's legacy opt-in.
+60
View File
@@ -0,0 +1,60 @@
# Security recovery and credential revocation
A password reset is an account-recovery event, not just a password-hash
change. The operator reset CLI revokes every access path for the target
account so a stolen session, native refresh token, long-lived API token,
trusted device, or temporary step-up proof cannot survive the new password.
Use the operator procedure in [Password reset](password-reset.md) and prefer
its hidden stdin prompt:
```sh
docker compose exec app node scripts/reset-password.mjs <username-or-email>
```
Do not put the new password in shell history, a ticket, chat, or logs.
## What a successful operator reset revokes
The CLI performs one serialized database transaction for exactly one matched
user. It:
1. locks the target account and writes the new password hash;
2. deletes every web session;
3. revokes every API access token;
4. revokes every native refresh token;
5. deletes every trusted device; and
6. deletes every live step-up elevation.
The safe completion message reports only the username and affected row counts.
It never prints the password, password hash, tokens, cookies, or health data.
Unrelated users are not changed.
The in-app administrative reset follows the same target-user revocation
policy for sessions, API tokens, native refresh tokens, and trusted device
records. Operators should still use the CLI recovery path when rollback-safe
direct recovery is required.
## Rollback guarantee
The operator CLI wraps identity resolution, password rotation, and every
revocation above in `BEGIN`/`COMMIT`. Any error issues `ROLLBACK` before the
database connection closes. A failed reset therefore does not leave a new
password paired with old credentials, or old password state paired with only
some credentials revoked.
Treat any non-zero exit as “nothing committed.” Correct the operational
problem and run the whole command again; do not manually complete a subset of
the SQL statements.
## After recovery
- Have the user sign in with the new password and enroll replacement trusted
devices intentionally.
- Expect every browser and native client to require a fresh login.
- Reissue only the minimum API or MCP scopes each integration needs.
- Review security activity without copying credential material into the
incident record.
- If compromise is suspected beyond one account, rotate the affected
instance-level secrets using the relevant operator runbooks before
restoring integrations.
Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

+125
View File
@@ -0,0 +1,125 @@
# Offline geolocation (GeoLite2)
The admin login overview shows a coarse `City, CC` location and a carrier
next to every sign-in. Two tiers can produce that:
- **Online.** A third-party lookup, `https://ipwho.is` by default. Free,
keyless, and on by default — which also means every login IP address of
every account on the instance leaves the host.
- **Offline.** MaxMind's GeoLite2-City and GeoLite2-ASN databases read
from a local directory. The ASN database is the authoritative source
for the carrier and the AS number. Note that the resolver is
online-first: having the databases in place is not by itself enough to
stop the outbound lookup, see "Turning the online lookup off entirely"
below.
The offline tier is optional and absent by default. The published image
only carries the databases when the image build had a MaxMind licence key
(`scripts/fetch-geolite2.sh`, run from the release workflow), which is a
build-time decision you do not control when you pull `:latest`. Bringing
your own copy is the way to get the offline tier on a self-host, and it
is the subject of this page.
## What you need
- A free MaxMind account. Sign up at
<https://www.maxmind.com/en/geolite2/signup>, then create a licence key
under **My Account → Manage License Keys**. GeoLite2 is free; the
account and key exist because MaxMind requires them for the download.
- `geoipupdate` (MaxMind's own downloader) or any other way to fetch the
two `.mmdb` files onto the host. Most distributions package
`geoipupdate`; there is also an official container image.
## Setup
1. Pick a directory on the host and fetch both databases into it. With
`geoipupdate`, `/etc/GeoIP.conf` looks like this:
```conf
AccountID <your-account-id>
LicenseKey <your-licence-key>
EditionIDs GeoLite2-City GeoLite2-ASN
DatabaseDirectory /srv/healthlog/geolite2
```
Then run `geoipupdate`. The directory should end up holding
`GeoLite2-City.mmdb` and `GeoLite2-ASN.mmdb`. The City database is the
one the readiness check looks for; the ASN database is what fills the
carrier and AS number.
2. Mount the directory into the app container read-only. In
`docker-compose.yml` the `app` service carries a commented `volumes:`
block for exactly this:
```yaml
volumes:
- /srv/healthlog/geolite2:/var/lib/geolite2:ro
```
3. Point the runtime at the container path:
```env
GEOLITE2_DIR="/var/lib/geolite2"
```
`GEOLITE2_DIR` is on the compose `environment:` whitelist, so a value
in your `.env` reaches the container. Variables that are not on that
whitelist never do, even when compose substitutes them elsewhere in
the file.
4. `docker compose up -d` and sign in once. **Admin → System status**
shows `Geo data: Offline (GeoLite2)` when the databases are being
read, and names the online provider when they are not.
## Turning the online lookup off entirely
Once the offline tier resolves, the third-party lookup is redundant. To
stop all IP egress:
```env
IP_GEO_LOOKUP_DISABLED="1"
```
The resolver then uses only the local databases and returns nothing when
they miss, rather than asking anyone. An instance handling other people's
health data is a reasonable place to make that trade.
## Keeping the databases current
MaxMind reissues GeoLite2 on its own schedule, and a stale city database
is quietly wrong rather than loudly broken — it keeps answering, with
last month's allocations. Run `geoipupdate` on a schedule (a weekly cron
or systemd timer is enough) against the same directory.
The reader loads each database once per worker process and holds it, so a
refreshed file on the host is picked up at the next container restart,
not immediately. A weekly `geoipupdate` followed by
`docker compose restart app` keeps the two in step.
## Licence and attribution
The databases are downloaded under your own MaxMind account, so their
terms are between you and MaxMind — read what your account agrees to at
<https://www.maxmind.com/en/geolite2/eula>. HealthLog's own attribution
("this product includes GeoLite2 data created by MaxMind") is already on
the `/about` page and covers an instance that uses them.
## Troubleshooting
**System status still says the online provider.** The readiness check
requires `GeoLite2-City.mmdb` to exist in `GEOLITE2_DIR` and no `.empty`
marker file beside it. Check the path from inside the container:
`docker compose exec app ls -l /var/lib/geolite2`. An empty listing means
the bind mount did not land; a `.empty` file means the image was built
without a licence key and you are still reading the baked-in directory
rather than your own.
**The variable has no effect.** Confirm it actually reached the process:
`docker compose exec app printenv GEOLITE2_DIR`. If it prints nothing,
the value is in `.env` but not on the `environment:` whitelist — compare
against `docker-compose.yml`.
**The carrier column is empty.** The carrier also comes from the online
provider's ISP field, so it is normally filled even without the ASN
database. If both tiers are off (`IP_GEO_LOOKUP_DISABLED="1"` and no
`GeoLite2-ASN.mmdb`), there is no source for it and it stays empty.
+10
View File
@@ -70,6 +70,16 @@ scope — it is the least-privilege MCP scope and nothing more, and it is
bound to the MCP surface: it is refused on every REST route. The same
list shows last-used time and lets you revoke any token.
MCP reads now require an explicit MCP read capability. A token must carry
`health:read` (or the legacy/native `*` wildcard) before the server registers
or invokes any read tool. `health:write`, `fhir:read`,
`medication:ingest`, notification scopes, and every other unrelated scope do
not imply `health:read`; presenting one of those narrow tokens to `/mcp` is
rejected before health data is accessed. Likewise, `health:read` does not
grant a regular REST read or any write. When upgrading, check any manually
provisioned connector token and mint a replacement with `health:read` instead
of broadening an unrelated credential.
Cloud connectors that use OAuth (next section) mint the same
`health:read` scope automatically, so for Claude.ai / ChatGPT you do not
need to mint a token by hand — the manual token is for the stdio / power-
@@ -81,6 +81,31 @@ These are cited as factual public record to explain _why_ the
"who holds the data" question is the one that matters for this category.
HealthLog makes no claim about any other product's current practices.
## What deleting actually does
Worth knowing before you rely on it, because "deleted" means two
different things depending on which button you press.
- **Deleting one cycle or day-log entry** hides it. Every read filters
it out and it is gone from the app, from exports, and from the doctor
report. The row itself stays in PostgreSQL as a tombstone, which is
what lets a phone that was offline at delete time learn that the entry
went away. No job removes cycle tombstones on a schedule, so the row
stays until one of the two actions below.
- **Settings → Delete All Data** removes the rows outright, tombstones
included. Everything the account owns goes except what you sign in
with; the confirmation dialog lists it.
- **Deleting the account** removes the account row, and the database
cascades every record with it.
Two consequences follow, and they matter for the seizure threat model
above. A host imaged while a per-entry deletion is the only deletion you
have performed still holds that entry. And a backup taken before any of
these actions still holds whatever it captured, because a backup is a
copy of a moment. If your reason for deleting is that the data must not
be recoverable from the machine, use one of the two whole-record actions
and rotate your backups.
## Practical hardening
If reproductive-health privacy is the reason you self-host, the
+2 -2
View File
@@ -324,7 +324,7 @@ The 1.4 release splits the legacy 3150-LOC settings monolith into one
route per top-level section:
- `/settings/account` — profile, password, passkeys, account deletion.
- `/settings/integrations` — Withings, moodLog, Telegram, ntfy, Web Push.
- `/settings/integrations` — Withings, Nightscout, Telegram, ntfy, Web Push.
- `/settings/notifications` — channel matrix + quiet hours + reminders.
- `/settings/dashboard` — tile layout editor, default ranges, threshold
overrides.
@@ -426,7 +426,7 @@ control. Behavior:
- **History**: each test writes a row to a tiny per-integration log
visible in the section. Last 5 tests, timestamp + result.
- **Coverage**: required for Telegram, ntfy, Web Push, AI provider,
Withings, moodLog, GlitchTip, Umami, email (when wired), backup
Withings, Nightscout, GlitchTip, Umami, email (when wired), backup
target. Headless E2E asserts presence + working call for each.
### 4.6 Server actions and mutations
+4 -2
View File
@@ -12,10 +12,12 @@ The Playwright config (`../playwright.config.ts`) builds the app (`next build`)
## Layout
- **`setup/`** — `global-setup.ts` (auth + DB seeding), `test-helpers.ts` (shared login + navigation helpers).
- **`setup/`** — `test.ts` (the suite's `test` + `expect` entry point), `global-setup.ts` (auth + DB seeding), `test-helpers.ts` (shared login + navigation helpers).
- **`utils/`** — fixtures such as `mock-dashboard-snapshot.ts`.
- **`*.spec.ts`** — one spec per flow: auth/login/redirect, dashboard, charts, the medication-wizard cadences (`medications-wizard-*`), insights generation + scroll restoration, doctor report, settings, mobile-viewport consistency, locale switch, accessibility (`a11y.spec.ts`), and the `v1427-*` regression set.
## Convention
## Conventions
Import `test` and `expect` from `./setup/test`, never from `@playwright/test` — types (`Page`, `Route`, …) still come from the package. The entry point extends Playwright's `test` with an automatic fixture that drops route handlers and page listeners after every test, so a handler left in flight cannot fail the test it belonged to once the context closes. `src/__tests__/e2e-route-cleanup-guard.test.ts` enforces the import boundary.
Assert against stable `data-*` attributes (`data-slot`, `data-display-step`, root display attrs), not viewport-dependent visible text. Responsive `sm:hidden` UI changes break `getByText` on desktop while the underlying element is still present — anchoring on data attributes keeps the specs stable across breakpoints. See [`../CLAUDE.md`](../CLAUDE.md).
+2 -7
View File
@@ -1,11 +1,6 @@
import AxeBuilder from "@axe-core/playwright";
import {
expect,
test,
type Locator,
type Page,
type Route,
} from "@playwright/test";
import type { Locator, Page, Route } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { POPULATED_SUMMARIES } from "./utils/mock-dashboard-snapshot";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
/**
* The proxy at src/proxy.ts is the single gate enforcing auth on every
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { mockDashboardSnapshot } from "./utils/mock-dashboard-snapshot";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { setTimeout as delay } from "node:timers/promises";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+70 -33
View File
@@ -1,20 +1,29 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
expectNoHorizontalOverflow,
PHONE_WIDTHS,
settleForOverflowMeasurement,
} from "./utils/horizontal-overflow";
/**
* Pixel-5 mobile smoke for the Insights overview + the cycle vertical
* (v1.15.10). Mirrors `mobile-viewport.spec.ts`. Asserts:
* Mobile smoke for the cycle vertical (v1.15.10). Asserts:
*
* 1. `/insights` has no horizontal page scroll on the Pixel-5 width — the
* core guarantee behind the cycle-ring-width fix (the 120 px ring tile
* used to overflow narrow grid cells).
* 2. `/cycle` has no horizontal page scroll on the Pixel-5 width — covers
* 1. `/cycle` has no horizontal page scroll at any phone width — covers
* the tab-strip-overflow fix (the four long German labels used to force
* a horizontal scroll at 375 px) and the responsive cycle ring.
* 3. The cycle-calendar day cells clear the WCAG 2.5.5 mobile tap-target
* 2. The cycle-calendar day cells clear the WCAG 2.5.5 mobile tap-target
* height floor (44 CSS px) — the `min-h-10` → `min-h-11` bump.
*
* The `/insights` width case that used to live here has moved to
* `mobile-horizontal-overflow.spec.ts`. It asserted against an EMPTY insights
* payload and against `documentElement.scrollWidth`, so it was green through a
* populated page that really did pan sideways inside the `AuthShell` `<main>`
* scroll container. The replacement runs populated fixtures at several phone
* widths and measures the scroll container the app actually uses. The cycle
* fixture below is populated for the same reason.
*
* The cycle feature gate is enabled by rewriting the real `/api/auth/me`
* response with `cycleTrackingEnabled: true` (every other field stays
* authentic), and the `/api/cycle/*` reads are mocked so the wheel, tab strip,
@@ -108,22 +117,48 @@ const HISTORY_BODY = {
};
const INSIGHTS_BODY = {
data: { rows: [], headline: null, symptomPatterns: [] },
data: {
rows: buildInsightRows(),
headline: {
symptom: "HEADACHE",
phase: "LUTEAL",
rate: 0.3,
confidence: "high",
},
symptomPatterns: buildInsightRows(),
},
error: null,
};
async function assertNoHorizontalScroll(page: import("@playwright/test").Page) {
const dims = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
innerWidth: window.innerWidth,
/**
* A populated cycle-insights payload. The previous fixture was
* `{ rows: [], headline: null, symptomPatterns: [] }` — an empty page, which
* fits any viewport by construction and proved nothing about the width
* guarantee it was written for. Long labels are deliberate: the tab strip and
* the summary rows are where narrow-width text pressure shows up.
*/
function buildInsightRows(): Array<Record<string, unknown>> {
const symptoms = [
"HEADACHE",
"CRAMPS",
"BLOATING",
"FATIGUE",
"MOOD_SWINGS",
"BREAST_TENDERNESS",
"BACK_PAIN",
"ACNE",
];
return symptoms.map((symptom, i) => ({
symptom,
phase: ["MENSTRUAL", "FOLLICULAR", "OVULATORY", "LUTEAL"][i % 4],
occurrences: 12 - i,
totalDays: 40,
rate: (12 - i) / 40,
confidence: i < 4 ? "high" : "low",
}));
expect(
dims.scrollWidth,
`scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`,
).toBeLessThanOrEqual(dims.innerWidth + 1);
}
test.describe("cycle + insights mobile smoke", () => {
test.describe("cycle mobile smoke", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({ page }, testInfo) => {
@@ -175,24 +210,26 @@ test.describe("cycle + insights mobile smoke", () => {
);
});
test("/insights has no horizontal scroll on Pixel-5", async ({ page }) => {
await page.goto("/insights", { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
await assertNoHorizontalScroll(page);
});
test("/cycle has no horizontal scroll and the calendar cells clear the 44 px tap floor", async ({
test("/cycle has no horizontal scroll at any phone width and the calendar cells clear the 44 px tap floor", async ({
page,
}) => {
for (const width of PHONE_WIDTHS) {
await page.setViewportSize({ width, height: 851 });
await page.goto("/cycle", { waitUntil: "domcontentloaded" });
// The cycle vertical mounted (the page redirects home if the gate is off).
const wheel = page.locator('[data-slot="cycle-wheel-tile"]');
await expect(wheel).toBeVisible();
await settleForOverflowMeasurement(page);
await expectNoHorizontalOverflow(page, `/cycle @${width}px`);
}
// Back to the Pixel-5 width for the tap-target measurement.
await page.setViewportSize({ width: 393, height: 851 });
await page.goto("/cycle", { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
// The cycle vertical mounted (the page redirects home if the gate is off).
const wheel = page.locator('[data-slot="cycle-wheel-tile"]');
await expect(wheel).toBeVisible();
// 1) No horizontal page scroll at the Pixel-5 width.
await assertNoHorizontalScroll(page);
await expect(page.locator('[data-slot="cycle-wheel-tile"]')).toBeVisible();
await page.waitForLoadState("networkidle").catch(() => {});
// 2) Calendar day cells clear the 44 px tap-target height floor.
const cells = await page.locator('[role="gridcell"]').all();
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { mockDashboardSnapshot } from "./utils/mock-dashboard-snapshot";
+2 -1
View File
@@ -16,7 +16,8 @@
* seeds a real `document_content_index` row (`ensureVaultAiFixture`) and drives
* the real list union.
*/
import { expect, test, type Page } from "@playwright/test";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+2 -1
View File
@@ -15,7 +15,8 @@
* AI e2e, so it is pinned here too. Assertions target stable data-slots, never
* viewport-dependent text.
*/
import { expect, test, type Page } from "@playwright/test";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+2 -1
View File
@@ -22,7 +22,8 @@
* the specs time out. The seeded demo user has no AI provider, so the
* no-provider state is the default.
*/
import { expect, test, type Page } from "@playwright/test";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+2 -1
View File
@@ -17,7 +17,8 @@
* they create, never on the shared doctor-flow corpus.
*/
import AxeBuilder from "@axe-core/playwright";
import { expect, test, type Page } from "@playwright/test";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
/**
* Locale-key drift smoke check — switch the cookie between EN and DE
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
/**
* Smoke checks on the login surface — must render, must wire the
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+2 -1
View File
@@ -1,4 +1,5 @@
import { expect, test, type Route } from "@playwright/test";
import type { Route } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+38 -16
View File
@@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { settleBeforeMeasure } from "./utils/settle";
import {
LONG_HEADLINE_BRIEFING,
MOCK_SCORE_RINGS,
@@ -8,6 +9,7 @@ import {
POPULATED_SUMMARIES,
} from "./utils/mock-dashboard-snapshot";
import { mockMoodInsights } from "./utils/mock-mood-insights";
import { expectNoHorizontalOverflow } from "./utils/horizontal-overflow";
/**
* Phone-width density guard (390×844 iPhone class, 360×780 small
@@ -17,8 +19,12 @@ import { mockMoodInsights } from "./utils/mock-mood-insights";
* squeezed into a one-word-per-line column beside a wide delta, the
* delta spilling past the tile edge at 360 px):
*
* 1. No horizontal page overflow — `scrollWidth` never exceeds the
* viewport width (1 px sub-pixel tolerance).
* 1. No horizontal page overflow — neither the document nor the `AuthShell`
* `<main>` scroll container exceeds the viewport width (1 px sub-pixel
* tolerance). The `<main>` half is load-bearing: this guard measured only
* the document until a report of sideways scroll on a populated Insights
* page showed the document never widens, because `<main>` absorbs the
* overflow into its own scrollable area.
* 2. Nothing escapes its tile — for every card / Today hero / list
* row, no descendant's visible box crosses the tile's left or
* right edge (clip-aware: content genuinely hidden by an inner
@@ -157,7 +163,16 @@ test.describe("phone-width density guard", () => {
await mockMoodInsights(page);
}
await page.goto(route, { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
if (route === "/insights") {
// The one route here that had no readiness signal, while its two
// siblings below both have one. An overflow guard against a page
// that has not finished rendering passes for the same reason an
// empty page passes: there is nothing wide on it yet.
await expect(
page.locator('[data-slot="section-heading"]').first(),
).toBeVisible();
}
if (route === "/") {
// The guard must not pass vacuously — the Today hero and its
@@ -181,19 +196,26 @@ test.describe("phone-width density guard", () => {
).toBeVisible();
}
const dims = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
bodyScrollWidth: document.body.scrollWidth,
innerWidth: window.innerWidth,
}));
await settleBeforeMeasure(page, page.locator("main#main-content"));
// The document/body pair alone cannot see the app's sideways scroll:
// `AuthShell` scrolls inside `<main id="main-content">`, whose
// `overflow-y:auto` makes `overflow-x` compute to `auto`, so an
// over-wide child grows `main.scrollWidth` while the document stays
// exactly viewport-wide. The shared helper asserts both areas and
// names the offending element.
await expectNoHorizontalOverflow(
page,
`${route} @${viewport.width}x${viewport.height}`,
);
const bodyScrollWidth = await page.evaluate(
() => document.body.scrollWidth,
);
const innerWidth = await page.evaluate(() => window.innerWidth);
expect(
dims.scrollWidth,
`page scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`,
).toBeLessThanOrEqual(dims.innerWidth + 1);
expect(
dims.bodyScrollWidth,
`body scrollWidth=${dims.bodyScrollWidth}`,
).toBeLessThanOrEqual(dims.innerWidth + 1);
bodyScrollWidth,
`body scrollWidth=${bodyScrollWidth}`,
).toBeLessThanOrEqual(innerWidth + 1);
const escapes = await page.evaluate(collectTileEscapes);
expect(
+255
View File
@@ -0,0 +1,255 @@
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { mockDashboardSnapshot } from "./utils/mock-dashboard-snapshot";
import { mockPopulatedInsights } from "./utils/mock-populated-insights";
import {
expectNoHorizontalOverflow,
measureHorizontalOverflow,
PHONE_WIDTHS,
settleForOverflowMeasurement,
} from "./utils/horizontal-overflow";
/**
* The content-heavy routes must not scroll sideways at any phone width.
*
* This replaces a per-surface habit of asserting
* `documentElement.scrollWidth <= innerWidth` on whatever happened to render.
* That assertion had two holes, and a real report walked through both:
*
* 1. It measured the DOCUMENT. `AuthShell` scrolls inside
* `<main id="main-content">`, whose `overflow-y:auto` makes `overflow-x`
* compute to `auto` — so an over-wide child grows `main.scrollWidth`, the
* user pans the page sideways, and the document never widens by a pixel.
* 2. It ran against an EMPTY page. A surface with no content fits any
* viewport, so the guard proved nothing about the surface people use.
*
* `expectNoHorizontalOverflow` closes (1) by asserting both scroll areas and
* naming the widest offending element on failure. The fixtures below close (2)
* by giving each route a body before it is measured.
*
* Widths cover real iPhone (390 / 393 / 402 / 430) and common Android (360)
* CSS widths — a single width hides anything whose overflow is width-dependent.
* Runs on `chromium-mobile` only; the desktop project skips the whole file.
*/
interface RouteCase {
path: string;
/** Something that must be on-screen before measuring — proves it is populated. */
ready: string;
setup?: (page: import("@playwright/test").Page) => Promise<void>;
}
const ROUTE_CASES: RouteCase[] = [
{
path: "/insights",
// The "signals of the day" section is the one whose heading carries the
// long provenance sentence, so it is also the readiness signal.
ready: '[data-slot="coincident-deviation-section"]',
setup: mockPopulatedInsights,
},
{
path: "/",
ready: "main#main-content",
setup: (page) => mockDashboardSnapshot(page),
},
{ path: "/measurements", ready: "main#main-content" },
{ path: "/medications", ready: "main#main-content" },
];
test.describe("content-heavy routes have no horizontal page scroll at phone widths", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({}, testInfo) => {
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
});
for (const routeCase of ROUTE_CASES) {
test(`${routeCase.path} does not scroll sideways on any phone width`, async ({
page,
}) => {
await routeCase.setup?.(page);
for (const width of PHONE_WIDTHS) {
await page.setViewportSize({ width, height: 851 });
await page.goto(routeCase.path, { waitUntil: "domcontentloaded" });
await expect(page.locator(routeCase.ready).first()).toBeVisible();
await settleForOverflowMeasurement(page);
await expectNoHorizontalOverflow(page, `${routeCase.path} @${width}px`);
}
});
}
/**
* The fixture itself has to stay honest. If `mockPopulatedInsights` ever
* stops populating the overview — a renamed route, a changed envelope — the
* width assertions above would keep passing against an empty page and the
* guard would quietly go back to proving nothing. Assert the page is
* actually carrying content, so fixture rot fails loudly instead.
*/
test("the /insights fixture really populates the overview", async ({
page,
}) => {
await mockPopulatedInsights(page);
await page.setViewportSize({ width: 390, height: 851 });
await page.goto("/insights", { waitUntil: "domcontentloaded" });
const signals = page.locator('[data-slot="coincident-deviation-section"]');
await expect(signals).toBeVisible();
// The prose in the heading's trailing action slot — the shape that widened
// the page. It must be present AND long enough to be able to.
const method = signals.locator('[data-slot="provenance-explainer-method"]');
await expect(method).toBeVisible();
const text = (await method.textContent()) ?? "";
expect(
text.length,
`provenance caption too short to exercise the overflow class: "${text}"`,
).toBeGreaterThan(60);
// And the surface as a whole is not the empty state.
await expect(
page.locator('[data-slot="section-heading"]').first(),
).toBeVisible();
const headingCount = await page
.locator('[data-slot="section-heading"]')
.count();
expect(headingCount, "overview rendered no sections").toBeGreaterThan(1);
});
/**
* A long trailing caption must WRAP inside the row, not extend it. Asserting
* the wrap directly (rather than only the page-level width) pins the fix at
* the element that broke: a `shrink-0` action slot sized itself to the
* caption's max-content width, ~1.2-1.5k px depending on locale.
*/
test("a long heading action caption wraps instead of widening the row", async ({
page,
}) => {
await mockPopulatedInsights(page);
await page.setViewportSize({ width: 390, height: 851 });
await page.goto("/insights", { waitUntil: "domcontentloaded" });
// Wait on the CAPTION, not the heading: the card first paints a skeleton
// whose heading carries no action slot, then swaps to the resolved shell.
// Both render `[data-slot="section-heading"]`, so gating on the heading
// alone can measure the skeleton — or measure nothing at all, because the
// element detaches during the swap.
const method = page
.locator(
'[data-slot="coincident-deviation-section"] [data-slot="provenance-explainer-method"]',
)
.first();
await expect(method).toBeVisible();
// Read both rects in ONE evaluate so the swap cannot land between them.
const box = await method.evaluate((el) => {
const headingEl = el.closest('[data-slot="section-heading"]');
if (!headingEl) return null;
const caption = el.getBoundingClientRect();
const heading = headingEl.getBoundingClientRect();
return {
captionWidth: caption.width,
headingWidth: heading.width,
viewportWidth: window.innerWidth,
};
});
expect(box, "caption is not inside a section heading").not.toBeNull();
expect(
Math.round(box!.captionWidth),
`caption width ${box!.captionWidth} exceeds its heading row ${box!.headingWidth} — the action slot is sizing to max-content instead of wrapping`,
).toBeLessThanOrEqual(Math.round(box!.headingWidth) + 1);
expect(
Math.round(box!.headingWidth),
"heading row is wider than the viewport",
).toBeLessThanOrEqual(box!.viewportWidth);
});
/**
* The helper must be able to SEE overflow through the `<main>` scroll
* container — the exact blind spot that let the previous guard pass. Inject
* a deliberately over-wide, un-contained block and assert the measurement
* reports it. Without this, a helper that silently measured the wrong
* element would make every assertion above vacuous.
*/
test("the overflow measurement detects an over-wide block inside <main>", async ({
page,
}) => {
await mockPopulatedInsights(page);
await page.setViewportSize({ width: 390, height: 851 });
await page.goto("/insights", { waitUntil: "domcontentloaded" });
// Wait for the same readiness signal and settle step every route case
// above uses. This was the one test in the file that measured and injected
// straight after `domcontentloaded`, against a tree that was still
// mounting, and it is the one that went flaky on CI: `main.scrollWidth`
// came back at exactly `clientWidth` with a 1500px child supposedly in it.
//
// Two candidate mechanisms were checked against a local run rather than
// assumed. A render swapping `main`'s children and taking the canary with
// it does NOT happen: the node survives the skeleton-to-content swap
// (12/12 runs, canary attached, scrollWidth 1500). That leaves `main` not
// yet BEING the scroll container at injection time, which is what settling
// rules out and what the assertions below now report on if it recurs.
await expect(
page.locator('[data-slot="coincident-deviation-section"]').first(),
).toBeVisible();
await settleForOverflowMeasurement(page);
const clean = await measureHorizontalOverflow(page);
expect(clean.mainScrollWidth).toBeLessThanOrEqual(
(clean.mainClientWidth ?? 0) + 1,
);
await page.evaluate(() => {
const probe = document.createElement("div");
probe.id = "overflow-canary";
probe.style.width = "1500px";
probe.style.height = "10px";
document.querySelector("main#main-content")!.appendChild(probe);
});
// Separate the fixture's own failure from the helper's. If the canary is
// not inside the CURRENT `main`, the measurement below proves nothing
// either way, and a failure that says so is a diagnosis rather than an
// accusation.
await expect(
page.locator("main#main-content #overflow-canary"),
"the canary is not inside the current <main> — the fixture failed, not the helper",
).toBeAttached();
// Captured so that a recurrence names its own cause instead of costing
// another round of guessing. `overflowX: clip` or a `main` that is not a
// scroll container both produce the observed "scrollWidth === clientWidth
// with a 1500px child" and are indistinguishable without this.
const state = await page.evaluate(() => {
const main = document.querySelector<HTMLElement>("main#main-content")!;
const canary = document.getElementById("overflow-canary");
const style = getComputedStyle(main);
return {
overflowX: style.overflowX,
overflowY: style.overflowY,
canaryWidth: canary?.getBoundingClientRect().width ?? null,
canaryInMain: canary ? main.contains(canary) : false,
};
});
const dirty = await measureHorizontalOverflow(page);
expect(
dirty.mainScrollWidth,
`an intentionally 1500px-wide block did not widen main's scroll area — main overflow-x:${state.overflowX} overflow-y:${state.overflowY}, canary width ${state.canaryWidth}, inside main: ${state.canaryInMain}`,
).toBeGreaterThan((dirty.mainClientWidth ?? 0) + 1);
expect(
dirty.offenders.some((o) => o.selector.includes("overflow-canary")),
`the canary was not reported as an offender; got: ${dirty.offenders
.map((o) => o.selector)
.join(" | ")}`,
).toBe(true);
// The document stayed exactly as wide as the viewport while `<main>` grew
// by 1500px — this is the blind spot, asserted rather than described.
expect(
dirty.docScrollWidth,
"document width changed, so the document-only assertion would have caught this after all",
).toBeLessThanOrEqual(dirty.innerWidth + 1);
});
});
+6 -2
View File
@@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { settleBeforeMeasure } from "./utils/settle";
import {
mockDashboardSnapshot,
WEIGHT_ONLY_SUMMARIES,
@@ -78,7 +79,10 @@ test.describe("mobile-viewport smoke", () => {
);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
// Gate on the dashboard's own content, not on the network. Every read
// below is geometric and happens exactly once, so measuring a tree that
// is still mounting reports a layout nobody will ever see.
await settleBeforeMeasure(page, page.locator("main#main-content"));
// 1) No horizontal scroll
const dims = await page.evaluate(() => ({
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+14 -9
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
@@ -41,11 +41,15 @@ test("medications list refetches on client-side navigation back to the page", as
}
});
// Initial load — the list query mounts and fetches once.
await page.goto("/medications");
await page.waitForResponse(
// Initial load — the list query mounts and fetches once. Arm the waiter
// BEFORE navigating: `goto` resolves on load, so a response that already
// arrived would never be seen by a listener attached afterwards, and the
// wait would time out on an event that had long since happened.
const firstListFetch = page.waitForResponse(
(r) => new URL(r.url()).pathname === "/api/medications",
);
await page.goto("/medications");
await firstListFetch;
expect(medsRequests).toBe(1);
// Client-side nav AWAY (dashboard) then BACK — App Router unmounts and
@@ -56,10 +60,11 @@ test("medications list refetches on client-side navigation back to the page", as
await page.locator('a[href="/medications"]').first().click();
await page.waitForURL("**/medications");
// Give a returning refetch time to fire.
await page.waitForTimeout(1500);
// Desired: the return mounts a fresh observer that refetches. Pre-fix
// the 5-min staleTime masks it and this stays at 1 (the bug).
expect(medsRequests).toBeGreaterThanOrEqual(2);
// the 5-min staleTime masks it and this stays at 1 (the bug). Poll rather
// than sleeping a fixed span: it returns the moment the refetch lands, and
// it does not turn a slow CI machine into a false failure.
await expect
.poll(() => medsRequests, { timeout: 10_000 })
.toBeGreaterThanOrEqual(2);
});
+187 -35
View File
@@ -1,4 +1,7 @@
import { expect, test } from "@playwright/test";
import type { Page } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
@@ -12,9 +15,11 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
* and generic-JSON cards.
* 3. The full health-record export panel lives on its own top-level
* `/settings/gesundheitsakte` section (v1.18.0 S5) — including the
* "included data" checklist, a disclosure collapsed by default that
* expands on demand.
* 4. Clicking the Measurements CSV download button fires a real
* "included data" disclosure, which a first run opens and which stays
* operable in both directions.
* 4. The scope picker mounts twice on that page (the export panel and the
* share-link create form) and each mount is separately addressable.
* 5. Clicking the Measurements CSV download button fires a real
* browser download — proving the `/api/export/measurements`
* endpoint is reachable from the browser end-to-end.
*
@@ -22,6 +27,30 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
* download is enough to lock in the wiring without doubling the e2e
* runtime.
*/
/**
* Open Settings → Gesundheitsakte and wait until the page is actually live.
*
* Everything on that page is server-rendered, so the rows are visible long
* before the client boundary that owns them has hydrated — a click or a
* `boundingBox()` taken on `domcontentloaded` alone hits inert or replaced
* markup often enough to matter. `GesundheitsakteSection` is one client
* boundary carrying both the export panel and the sharing card, and the
* sharing card's query only runs once that boundary is live, so its request is
* the signal that the whole surface is wired.
*/
async function openGesundheitsakte(page: Page): Promise<void> {
const boundaryHydrated = page.waitForResponse(
(response) =>
response.request().method() === "GET" &&
new URL(response.url()).pathname === "/api/share-links",
);
await page.goto("/settings/gesundheitsakte", {
waitUntil: "domcontentloaded",
});
await boundaryHydrated;
}
test.describe("Settings → Export & Import", () => {
test.use({ storageState: STORAGE_STATE_PATH });
@@ -45,14 +74,136 @@ test.describe("Settings → Export & Import", () => {
}) => {
// v1.18.0 (S5) — the health-record export is the hero of the
// dedicated, module-gated Gesundheitsakte section.
await page.goto("/settings/gesundheitsakte", {
waitUntil: "domcontentloaded",
});
await openGesundheitsakte(page);
await expect(page.getByTestId("health-record-export-panel")).toBeVisible({
timeout: 10_000,
});
});
test("gives each scope-picker mount its own test ids", async ({ page }) => {
// The Gesundheitsakte page carries two mounts of the same picker: the
// export panel at the top and the share-link create form under `#sharing`.
// Every id inside the picker ends with the surface that mounts it, so an
// assertion binds to one mount instead of resolving to both.
await openGesundheitsakte(page);
await expect(page.getByTestId("report-scope-picker-export")).toHaveCount(1);
await expect(page.getByTestId("report-scope-picker-share")).toHaveCount(1);
for (const id of [
"report-group-row-vitals",
"report-group-check-vitals",
"report-sensitive-tier",
"report-scope-summary",
]) {
await expect(page.getByTestId(`${id}-export`)).toHaveCount(1);
await expect(page.getByTestId(`${id}-share`)).toHaveCount(1);
}
});
test("opens the scope picker expanded on a first run, fenced tier and all", async ({
page,
}) => {
// The three panel states are the release's whole point, and on the first
// one nothing is selected at all. The picker still opens without a click,
// because with an empty scope the standard-report button and the named
// groups are exactly what the person needs in front of them. Asserted
// against the export mount; the share form runs its own picker.
await openGesundheitsakte(page);
await expect(page.getByTestId("report-scope-picker-export")).toBeVisible({
timeout: 10_000,
});
await expect(
page.getByTestId("report-group-row-vitals-export"),
).toBeVisible();
await expect(
page.getByTestId("report-group-row-sensitive-export"),
).toHaveCount(0);
await expect(
page.getByTestId("report-sensitive-tier-export"),
).toBeVisible();
// The fenced tier has no group control — there is no single click that
// turns on more than one sensitive leaf.
await expect(
page.getByTestId("report-group-check-sensitive-export"),
).toHaveCount(0);
await expect(page.getByTestId("report-scope-summary-export")).toBeVisible();
});
test("first run selects nothing and refuses to generate until it does", async ({
page,
}) => {
// The rule the whole surface exists for: nothing reaches the report that
// was not ticked. So a first run starts empty, Generate refuses the press
// and says why, and the one-click action is what makes that cost nothing —
// it fills the standard set and the control comes alive.
await openGesundheitsakte(page);
const generate = page.getByTestId("health-record-generate");
await expect(generate).toBeVisible({ timeout: 10_000 });
await expect(generate).toBeDisabled();
await expect(
page.getByTestId("report-group-row-vitals-export"),
).toContainText("0/7");
await expect(page.getByTestId("report-scope-summary-export")).toContainText(
"Nothing selected yet",
);
await page.getByTestId("report-apply-standard-export").click();
await expect(generate).toBeEnabled();
// Exactly the shipped set: the three classic vitals, weight and BMI, and
// nothing at all from the groups the template leaves alone.
await expect(
page.getByTestId("report-group-row-vitals-export"),
).toContainText("3/7");
await expect(
page.getByTestId("report-group-row-body-export"),
).toContainText("2/12");
await expect(
page.getByTestId("report-group-row-cardio-export"),
).toContainText("0/14");
// The fenced tier is not part of any one-click set.
await expect(
page.getByTestId("report-scope-summary-export"),
).not.toContainText("incl.");
});
test("a share link cannot be minted on an empty scope either", async ({
page,
}) => {
await openGesundheitsakte(page);
const create = page.getByTestId("share-create-submit");
await expect(create).toBeVisible({ timeout: 10_000 });
await page.locator("#share-label").fill("Cardiology");
// A label alone is not a scope: the link would serve a page with nothing
// on it, so the control stays shut until something is chosen.
await expect(create).toBeDisabled();
await expect(page.getByTestId("report-scope-summary-share")).toContainText(
"Nothing selected yet",
);
await page.getByTestId("report-apply-standard-share").click();
await expect(create).toBeEnabled();
});
test("clears the tap floor on the group rows at 390 px", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await openGesundheitsakte(page);
const row = page.getByTestId("report-group-row-vitals-export");
await expect(row).toBeVisible({ timeout: 10_000 });
const box = await row.boundingBox();
expect(box).not.toBeNull();
expect(box!.height).toBeGreaterThanOrEqual(44);
});
test("opens a group's leaf grid from its chevron", async ({ page }) => {
await openGesundheitsakte(page);
const row = page.getByTestId("report-group-row-vitals-export");
await expect(row).toBeVisible({ timeout: 10_000 });
// Collapsed: the leaves are behind the disclosure, which is what makes a
// repeat run two interactions.
await expect(page.getByTestId("report-leaf-PULSE-export")).toHaveCount(0);
await row.getByRole("button", { expanded: false }).click();
await expect(page.getByTestId("report-leaf-PULSE-export")).toBeVisible();
});
test("renders the import surfaces with stable testids", async ({ page }) => {
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
// v1.15.7 (issue #281) — the Apple Health and generic-JSON import
@@ -64,18 +215,20 @@ test.describe("Settings → Export & Import", () => {
test("JSON import 'Download example' fires a real download", async ({
page,
isMobile,
}) => {
// Playwright's mobile emulation (Pixel 5 / isMobile) does not reliably
// emit a `download` event for a Blob + anchor click — the click is treated
// as a navigation, so `waitForEvent("download")` times out. The download
// wiring is engine behaviour, not viewport-dependent, and is covered on the
// chromium-desktop project; skip it on mobile rather than chase a flake.
test.skip(isMobile, "downloads aren't observable under mobile emulation");
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
const exampleBtn = page.getByTestId("import-json-download-example");
await expect(exampleBtn).toBeVisible({ timeout: 10_000 });
const a11y = await new AxeBuilder({ page })
.include('[data-testid="import-card-json"]')
.analyze();
expect(
a11y.violations.filter(
({ impact }) => impact === "serious" || impact === "critical",
),
).toEqual([]);
const downloadPromise = page.waitForEvent("download", { timeout: 30_000 });
await exampleBtn.click();
const download = await downloadPromise;
@@ -89,11 +242,18 @@ test.describe("Settings → Export & Import", () => {
expect(Array.isArray(parsed.moodEntries)).toBe(true);
});
test("included-data checklist is collapsed by default and expands on demand", async ({
test("included-data disclosure opens on a first run and still collapses", async ({
page,
}) => {
// v1.18.0 (S5) — the panel (and its disclosure) live on the
// dedicated Gesundheitsakte section now.
// The disclosure that fronts the scope picker on
// `/settings/gesundheitsakte`. An account with no saved selection is a
// first run, and a first run opens it: that is the only place the standard
// template is applied, and a template hidden behind a closed disclosure
// would be opt-out wearing a hat.
//
// Opening by default must not turn the disclosure into decoration, so the
// operable part is asserted in both directions — it closes, taking the
// panel with it, and it reopens.
await page.route("**/api/share-links", async (route) => {
if (route.request().method() !== "GET") {
await route.continue();
@@ -105,29 +265,21 @@ test.describe("Settings → Export & Import", () => {
body: JSON.stringify({ data: [], error: null, meta: null }),
});
});
const sharingDataReady = page.waitForResponse(
(response) =>
response.status() === 200 &&
response.request().method() === "GET" &&
new URL(response.url()).pathname === "/api/share-links",
);
await page.goto("/settings/gesundheitsakte", {
waitUntil: "domcontentloaded",
});
await sharingDataReady;
await openGesundheitsakte(page);
const toggle = page.getByTestId("health-record-included-data-toggle");
const panel = page.getByTestId("health-record-included-data-panel");
await expect(toggle).toBeVisible({ timeout: 10_000 });
// Collapsed on first render — the checklist panel is absent.
// Open on first render, with the checklist panel behind it.
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(panel).toBeVisible();
// Collapses on demand, and the panel goes with it.
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(
page.getByTestId("health-record-included-data-panel"),
).toHaveCount(0);
// Expands on demand.
await expect(panel).toHaveCount(0);
// And back — the disclosure stays a disclosure.
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(
page.getByTestId("health-record-included-data-panel"),
).toBeVisible();
await expect(panel).toBeVisible();
});
test("Measurements CSV download fires a real download event", async ({
+61 -30
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
@@ -45,7 +45,6 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
ntfyGlobal: true,
webPushGlobal: true,
apiGlobal: true,
moodLogGlobal: true,
},
error: null,
}),
@@ -53,6 +52,9 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
);
const recent = new Date(Date.now() - 12 * 60 * 1000).toISOString();
const monthAgo = new Date(
Date.now() - 28 * 24 * 60 * 60 * 1000,
).toISOString();
await page.route("**/api/integrations/status", (route) =>
route.fulfill({
@@ -72,7 +74,7 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
// v1.12.1 — the cards read connection state from this
// consolidated envelope (the per-provider routes were
// dropped from the section). The Withings pill keys on
// `connected`; the moodLog pill keys on `configured`.
// `connected`; a card-less pill keys on `configured`.
configured: true,
connected: true,
connectedAt: "2026-04-01T12:00:00Z",
@@ -81,19 +83,26 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
tokenExpired: false,
scope: "user.info,user.metrics,user.activity",
hasActivityScope: true,
syncHealth: { verdict: "fresh", since: null },
metricFreshness: [
{ type: "WEIGHT", lastSeenAt: recent, stale: false },
],
},
{
integration: "moodlog",
// Configured, card-less, and no longer attempting: the case
// the fallback row exists for. Synthetic since v1.32.33 —
// every shipped provider has a card again, and the row must
// stay proven before the next one is dropped.
integration: "example-provider",
state: "connected",
lastSuccessAt: recent,
lastAttemptAt: recent,
lastError: null,
consecutiveFailures: 0,
configured: true,
enabled: true,
legacyLastSyncedAt: recent,
webhookSecret: "ml_xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
entryCount: 42,
legacyLastSyncedAt: monthAgo,
syncHealth: { verdict: "stalled", since: monthAgo },
metricFreshness: [],
},
// v1.28.x — Strava OAuth card reads off this consolidated
// envelope; without a `strava` entry the new card's query key
@@ -108,6 +117,23 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
configured: false,
available: false,
hasOwnCredentials: false,
syncHealth: { verdict: "disconnected", since: null },
metricFreshness: [],
},
// Nightscout reads the envelope too since the card dropped its
// own status round-trip; without an entry its pill never resolves.
{
integration: "nightscout",
state: "disconnected",
lastSuccessAt: null,
lastAttemptAt: null,
lastError: null,
connected: false,
configured: false,
hasToken: false,
allowPrivateHost: false,
syncHealth: { verdict: "disconnected", since: null },
metricFreshness: [],
},
],
},
@@ -133,23 +159,6 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
}),
);
await page.route("**/api/integrations/moodlog/status", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
configured: true,
enabled: true,
lastSyncedAt: recent,
entryCount: 42,
webhookSecret: "ml_xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
},
error: null,
}),
}),
);
await page.goto("/settings/integrations", {
waitUntil: "domcontentloaded",
});
@@ -157,10 +166,27 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
// Wait for the pills to mount (queries resolve after first paint).
// One pill per card: Withings, WHOOP, Fitbit, Google Health, Polar,
// Oura, Strava, Nightscout. (The Garmin info note is not an OAuth card and
// carries no pill.)
// Oura, Strava, Nightscout — plus the fallback row for the configured,
// card-less entry. (The Garmin info note is not an OAuth card and
// carries no pill; the Apple Health card's pill keeps its own testid.)
const pills = page.locator('[data-testid="integration-status-pill"]');
await expect(pills).toHaveCount(8, { timeout: 10_000 });
await expect(pills).toHaveCount(9, { timeout: 10_000 });
// The acceptance case: a configured integration with no card of its own,
// which has stopped even attempting, is visible on the page.
const fallback = page.locator(
'[data-testid="integration-fallback-row"][data-integration="example-provider"]',
);
await expect(fallback).toHaveCount(1);
await expect(
fallback.locator('[data-testid="integration-status-pill"]'),
).toHaveAttribute("data-state", "stalled");
// Card anatomy is uniform now that Nightscout, the OAuth cards and Apple
// Health all carry the divider.
await expect(
page.locator('[data-testid="integration-card-divider"]'),
).toHaveCount(9);
// Withings is connected in this fixture; WHOOP is a BYO-keys card that
// stays dormant until an operator adds credentials, so assert the one
@@ -210,7 +236,12 @@ test.describe("Apple Health guidance", () => {
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { entries: [], lastSyncedAt: null },
data: {
entries: [],
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
metricFreshness: [],
},
error: null,
}),
}),
@@ -224,7 +255,7 @@ test.describe("Apple Health guidance", () => {
await expect(card).toContainText("not a web or OAuth connection");
await expect(page.getByTestId("apple-health-status")).toHaveAttribute(
"data-state",
"setup",
"pending-setup",
);
const importLink = page.getByTestId("apple-health-import-link");
+114 -14
View File
@@ -1,7 +1,10 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
mockDashboardSnapshot,
WEIGHT_ONLY_SUMMARIES,
} from "./utils/mock-dashboard-snapshot";
/**
* v1.4.19 A6 — Settings consistency snapshot at Pixel-5 (393 CSS px).
* v1.4.27 — input height floor lifted from 36 px (`h-9`) to 40 px
@@ -94,7 +97,11 @@ test.describe("Settings mobile consistency (Pixel 5)", () => {
page,
}) => {
await page.goto("/settings/account", { waitUntil: "networkidle" });
await page.waitForLoadState("networkidle");
// Same reason as the grid test below: gate on the element, not the
// network, because the read underneath it happens exactly once.
await expect(
page.getByRole("button", { name: /change password/i }),
).toBeVisible();
// The account page's action button(s) must live within (or above the
// bottom of) the parent card. Since v1.18.1 the "restart onboarding"
@@ -146,7 +153,13 @@ test.describe("Settings mobile consistency (Pixel 5)", () => {
page,
}) => {
await page.goto("/settings/account", { waitUntil: "networkidle" });
await page.waitForLoadState("networkidle");
// Gate on the two elements this test is about, not on the network. An idle
// network says nothing about whether React has rendered the profile form,
// and the single `evaluate` below does not retry — so without this the
// test reads an empty document and reports "language + dob fields must
// exist", which sounds like a missing feature and is really a race.
await expect(page.locator("#language-select")).toBeAttached();
await expect(page.locator("#dob")).toBeAttached();
// The v1.4.27 R1 settings audit pairs date-of-birth with language
// in a single `grid sm:grid-cols-2` row so the profile form keeps
@@ -170,23 +183,110 @@ test.describe("Settings mobile consistency (Pixel 5)", () => {
);
});
test("/settings/dashboard: Compare-to trigger renders at 44 px", async ({
page,
}) => {
await page.goto("/settings/dashboard", { waitUntil: "networkidle" });
await page.waitForLoadState("networkidle");
test("/: Compare-to trigger renders at 44 px", async ({ page }) => {
// v1.34.0 — the dashboard-settings comparison-baseline control was
// removed; the same preference now lives behind the per-chart overlay
// popover (see chart-overlay-controls.spec.ts for the full open/toggle
// flow). That popover only paints once a chart with a real chartKey
// renders, so this test needs the same seeded-chart mocks that spec
// uses rather than the real (likely-empty) e2e database.
await mockDashboardSnapshot(page, { summaries: WEIGHT_ONLY_SUMMARIES });
await page.route(/\/api\/analytics(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
summaries: {
WEIGHT: {
latest: 78.5,
avg7: 78.2,
avg30: 77.9,
slope30: { slope: -0.05, direction: "down" },
count: 30,
},
},
bpInTargetPct: 0,
glucoseByContext: {},
},
error: null,
}),
}),
);
await page.route("**/api/mood/analytics", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { entries: [], summary: { count: 0 } },
error: null,
}),
}),
);
await page.route("**/api/measurements*", (route) => {
const measurements = Array.from({ length: 10 }, (_, i) => ({
id: `m_${i}`,
type: "WEIGHT",
value: 78 + (i % 3) - 1,
measuredAt: new Date(Date.now() - i * 86_400_000).toISOString(),
notes: null,
}));
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { measurements, meta: { total: measurements.length } },
error: null,
}),
});
});
const trigger = page.locator("#comparison-baseline");
await expect(trigger).toBeVisible();
const h = await trigger.evaluate((el) => el.getBoundingClientRect().height);
expect(Math.round(h)).toBe(44);
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(
page.locator('[data-slot="dashboard-tile-strip"]'),
).toBeVisible({ timeout: 10_000 });
const trigger = page
.locator('[data-slot="chart-overlay-controls-trigger"]')
.first();
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.scrollIntoViewIfNeeded();
await trigger.click();
// `data-slot="chart-overlay-comparison-baseline"` is the grid WRAPPER
// (line 235 in chart-overlay-controls.tsx); the buttons themselves are
// `data-slot="chart-overlay-comparison-{none|lastMonth|lastYear}"`.
// Match only the wrapper's children so the wrapper's own (differently
// sized) box never gets measured as if it were a tap target.
const baselineButton = page
.locator(
'[data-slot="chart-overlay-comparison-baseline"] [data-slot^="chart-overlay-comparison-"]',
)
.first();
await expect(baselineButton).toBeVisible({ timeout: 5_000 });
// `min-h-11` sets the CSS contract (`min-height: 44px`), the same
// guarantee `h-11` gives the other 44px checks in this spec. This
// element additionally carries an explicit `h-8` from the Button "sm"
// size variant that `min-height` overrides; on Playwright's Pixel 5
// profile (fractional deviceScaleFactor) that combination measures a
// `getBoundingClientRect().height` a device pixel or two under 44 even
// though the computed CSS is exactly 44px — a viewport-emulation
// rounding artifact, not a real layout regression. Assert the
// authoritative computed style instead of the physically-snapped rect.
const minHeight = await baselineButton.evaluate(
(el) => getComputedStyle(el).minHeight,
);
expect(minHeight).toBe("44px");
});
test("/settings/ai: every native select renders at 44 px", async ({
page,
}) => {
await page.goto("/settings/ai", { waitUntil: "networkidle" });
await page.waitForLoadState("networkidle");
// `evaluateAll` over an empty match set returns an empty array rather than
// waiting, so the count assertion below would be the thing that fails and
// it would blame the page for having no selects. Wait for the first one.
await expect(page.locator("section select").first()).toBeVisible();
const heights = await page
.locator("section select")
+7 -4
View File
@@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { settleBeforeMeasure } from "./utils/settle";
/**
* Vertical over-scroll regression guard (the recurring class behind the
@@ -63,9 +64,11 @@ test.describe("settings + admin vertical over-scroll guard", () => {
test(`no over-scroll on ${route} (${vp.name})`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height });
await page.goto(route, { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
// Let entry transitions / late queries settle before measuring.
await page.waitForTimeout(500);
// Was a fixed 500 ms sleep, which is the wrong shape twice over: too
// long when the page is ready and too short on a loaded runner, and
// it never says what it is waiting for. Gate on the element the
// measurement reads instead.
await settleBeforeMeasure(page, page.locator("#main-content"));
const dims = await page.evaluate(() => {
const main = document.getElementById("main-content");
+90
View File
@@ -0,0 +1,90 @@
/**
* The E2E suite's `test` / `expect` entry point.
*
* Every spec imports from here instead of from `@playwright/test`, and the
* structural guard `src/__tests__/e2e-route-cleanup-guard.test.ts` keeps it
* that way.
*
* ## Why
*
* A spec that registers `page.route(...)` in a `beforeEach` leaves the handler
* armed until the context closes. If the handler is async — `route.fetch()`,
* an `await` before `route.fulfill()` — teardown can close the context while
* one invocation is still in flight. The callback then rejects against a dead
* target, Playwright attributes the rejection to the test that just finished,
* and a test that had already passed is reported as failed. With `retries: 2`
* plus `failOnFlakyTests` on CI, that is a release blocker whose trigger is
* timing, not code.
*
* The remedy Playwright names is `unrouteAll({ behavior: "ignoreErrors" })`
* before the test ends. Written per spec that is 36 copies of the same three
* lines and one forgotten copy away from the same failure. Written as an auto
* fixture it happens for every test in the suite, including specs nobody has
* written yet, and a spec author never has to know it exists.
*
* ## What it covers
*
* - `page.route(...)` on every page in the test's context.
* - `context.route(...)` on the context itself.
* - Page event listeners for the interactive event types a spec subscribes to.
* A listener that awaits something (a `dialog` handler, a `response` handler
* that reads a body) can reject after teardown for exactly the same reason a
* route handler can.
*
* ## What it does not cover
*
* A page that lives in a context the spec created itself — `browser.newContext()`
* inside a helper — is invisible here, because this fixture can only see the
* context Playwright built for the test. `share-documents.spec.ts` is the one
* spec that does this; it owns those contexts and has to clean them up itself.
*/
import { test as base, expect } from "@playwright/test";
/**
* Page event types cleared at teardown. Deliberately not the lifecycle events
* (`close`, `crash`, `frameattached`, …): those describe the teardown we are
* standing in, and nothing in the suite hangs work off them. These are the
* types a spec attaches a handler to, and the ones whose handler can still be
* running when the context goes away.
*/
const OBSERVED_PAGE_EVENTS = [
"console",
"dialog",
"download",
"filechooser",
"pageerror",
"popup",
"request",
"requestfailed",
"requestfinished",
"response",
"websocket",
"worker",
] as const;
export const test = base.extend<{ routeCleanup: void }>({
routeCleanup: [
async ({ context }, use) => {
await use();
// Best-effort: a spec is free to close its own context (or the browser
// can die under it), and a teardown helper must never be the thing that
// turns a green run red. Everything below is a cleanup, so a failure to
// clean up has nothing left to protect.
try {
for (const page of context.pages()) {
await page.unrouteAll({ behavior: "ignoreErrors" });
for (const event of OBSERVED_PAGE_EVENTS) {
await page.removeAllListeners(event, { behavior: "ignoreErrors" });
}
}
await context.unrouteAll({ behavior: "ignoreErrors" });
} catch {
// Target already gone — nothing left to unroute.
}
},
{ auto: true },
],
});
export { expect };
+7
View File
@@ -299,8 +299,15 @@ async function upsertDocs(
$8, 'binary2', $9, 'STORED',
$10::timestamp, NOW(), NOW(), NULL)
ON CONFLICT (id) DO UPDATE SET
user_id = EXCLUDED.user_id,
kind = EXCLUDED.kind,
title = EXCLUDED.title,
filename = EXCLUDED.filename,
mime_type = EXCLUDED.mime_type,
byte_size = EXCLUDED.byte_size,
content_encrypted = EXCLUDED.content_encrypted,
content_codec = EXCLUDED.content_codec,
content_sha256 = EXCLUDED.content_sha256,
document_date = EXCLUDED.document_date,
status = 'STORED',
deleted_at = NULL,
+10 -4
View File
@@ -24,7 +24,8 @@
* broader corpus (`ensureVaultFixture`) supplies the ≥50 documents the picker
* cap test and the not-attached foreign-id probe need.
*/
import { expect, test, type Browser, type Page } from "@playwright/test";
import type { Browser, Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
@@ -50,9 +51,10 @@ interface CreatedShare {
}
/**
* Drive the owner create flow: open the picker, isolate the share-fixture trio
* by search, select all three, create the link, and read the one-time token +
* passphrase off the reveal card. Also asserts the QR promotion.
* Drive the owner create flow: name the link, choose a record scope, open the
* picker, isolate the share-fixture trio by search, select all three, create
* the link, and read the one-time token + passphrase off the reveal card. Also
* asserts the QR promotion.
*/
async function createShareWithDocs(page: Page): Promise<CreatedShare> {
const label = `${SHARE_DOC_PREFIX} ${Date.now()}`;
@@ -66,6 +68,10 @@ async function createShareWithDocs(page: Page): Promise<CreatedShare> {
await page.goto("/settings/sharing");
await sharingDataReady;
await page.locator("#share-label").fill(label);
// A record link starts with an empty scope and cannot be minted on one, so
// the scope is chosen here the way an owner chooses it: one click on the
// named standard set.
await page.getByTestId("report-apply-standard-share").click();
// Open the document picker and isolate the seeded trio.
await page.getByTestId("share-attach-open").click();
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+32 -13
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import pg from "pg";
import { E2E_USER, STORAGE_STATE_PATH } from "./setup/global-setup";
@@ -50,35 +50,53 @@ test("a fresh arrival surfaces the just-in chip and flips the day in place", asy
test.skip(!dbUrl, "DATABASE_URL is required to seed the arrival marker");
const pool = new pg.Pool({ connectionString: dbUrl });
const client = await pool.connect();
try {
const { rows } = await pool.query<{ id: string; timezone: string | null }>(
"SELECT id, timezone FROM users WHERE username = $1",
[E2E_USER.username],
// Both Playwright projects deliberately exercise this real-data flow, but
// they share the one authenticated fixture user. Keep the two viewport
// runs from deleting/inserting the same arrival marker concurrently; the
// lock is scoped to this database session and releases even if the test
// fails. This preserves real Desktop + Mobile coverage instead of hiding
// the race behind a project skip.
await client.query(
"SELECT pg_advisory_lock(hashtext('e2e:today-just-in'))",
);
const { rows } = await client.query<{
id: string;
timezone: string | null;
}>("SELECT id, timezone FROM users WHERE username = $1", [
E2E_USER.username,
]);
expect(rows.length, "seeded e2e user must exist").toBe(1);
const userId = rows[0].id;
const timezone = rows[0].timezone ?? "UTC";
// A clean slate: no marker, and last night's sleep not yet in. Both are
// preconditions for the "before" assertion to mean anything.
await pool.query("DELETE FROM arrival_reactions WHERE user_id = $1", [
// A clean reaction slate: no marker and no final morning-refresh stamp.
// Older health data may remain, but it must not masquerade as newly
// arrived data before this scenario records its own sleep sample.
await client.query("DELETE FROM arrival_reactions WHERE user_id = $1", [
userId,
]);
await pool.query(
await client.query(
"UPDATE users SET morning_digest_refreshed_on = NULL WHERE id = $1",
[userId],
);
// ── BEFORE ────────────────────────────────────────────────────────────
// The globally-seeded account is intentionally empty. Wait until the
// digest settles, then prove there is no stale hero or arrival chip.
// Wait until the digest settles, then prove there is no stale arrival
// reaction. Other browser specs deliberately reuse this account and may
// have recorded older health data already, so an ordinary briefing Hero
// is a valid baseline; the invariant here is that it has no just-in chip.
await page.goto("/");
await expect(page.locator('[data-slot="today-hero-skeleton"]')).toHaveCount(
0,
);
const hero = page.locator('[data-slot="today-hero"]');
await expect(hero).toHaveCount(0);
await expect(page.locator('[data-slot="today-hero-just-in"]')).toHaveCount(
0,
);
// ── NEW DATA LANDS ────────────────────────────────────────────────────
// Last night's sleep through the real ingest route, on the session the
@@ -111,7 +129,7 @@ test("a fresh arrival surfaces the just-in chip and flips the day in place", asy
// morning-refresh stamp it rides alongside — the two rows that together
// make the day final and the arrival news.
const localDate = localDayKey(now, timezone);
await pool.query(
await client.query(
`INSERT INTO arrival_reactions
(id, user_id, kind, local_date, occurred_at, arrived_at, created_at)
VALUES ($1, $2, 'sleep_night', $3, $4, NOW(), NOW())
@@ -120,7 +138,7 @@ test("a fresh arrival surfaces the just-in chip and flips the day in place", asy
arrived_at = NOW()`,
[`c${now.getTime()}justin000000`, userId, localDate, wokeAt],
);
await pool.query(
await client.query(
"UPDATE users SET morning_digest_refreshed_on = $2 WHERE id = $1",
[userId, localDate],
);
@@ -146,6 +164,7 @@ test("a fresh arrival surfaces the just-in chip and flips the day in place", asy
hero.locator('[data-slot="today-hero-just-in"]'),
).toHaveAttribute("data-just-in-kind", "sleep_night");
} finally {
client.release();
await pool.end();
}
});
+205
View File
@@ -0,0 +1,205 @@
import { expect, type Page } from "@playwright/test";
/**
* Shared horizontal-overflow guard for phone widths.
*
* WHY THIS MEASURES `<main>` AND NOT JUST THE DOCUMENT
* ----------------------------------------------------
* The obvious assertion — `documentElement.scrollWidth <= innerWidth` — cannot
* observe the app's real sideways scroll. `AuthShell` puts every route inside
*
* <main id="main-content" class="… overflow-y-auto …">
*
* and CSS says that when one of `overflow-x` / `overflow-y` is not `visible`,
* the other computes to `auto`. So `<main>` is itself a horizontal scroll
* container: an over-wide child grows `main.scrollWidth`, the user pans the
* page sideways, and `documentElement.scrollWidth` never budges because
* `<main>` absorbed the overflow. A document-only assertion is green through
* exactly the defect a person reports as "I can scroll sideways off the page".
*
* Both measurements are therefore asserted. On failure the helper names the
* widest element that is actually extending the scroll area (skipping anything
* an ancestor already scrolls or clips — a strip that is *meant* to pan inside
* its own `overflow-x-auto` container is not a defect), with its measured
* width and the viewport width, so a future failure is actionable without a
* screenshot.
*/
/** Real iPhone + common Android CSS widths. */
export const PHONE_WIDTHS = [360, 390, 393, 402, 430] as const;
export interface OverflowOffender {
selector: string;
rectWidth: number;
rectRight: number;
scrollWidth: number;
path: string;
}
export interface OverflowReport {
innerWidth: number;
docScrollWidth: number;
/** null when the route renders outside `AuthShell` (no `<main id>`). */
mainScrollWidth: number | null;
mainClientWidth: number | null;
offenders: OverflowOffender[];
}
/**
* Measure horizontal overflow of the document AND of the `AuthShell` scroll
* container, plus the elements responsible.
*/
export async function measureHorizontalOverflow(
page: Page,
): Promise<OverflowReport> {
return page.evaluate(() => {
const vw = window.innerWidth;
const de = document.documentElement;
const main = document.querySelector<HTMLElement>("main#main-content");
const report = {
innerWidth: vw,
docScrollWidth: de.scrollWidth,
mainScrollWidth: main ? main.scrollWidth : null,
mainClientWidth: main ? main.clientWidth : null,
offenders: [] as Array<{
selector: string;
rectWidth: number;
rectRight: number;
scrollWidth: number;
path: string;
}>,
};
const overflows =
de.scrollWidth > vw + 1 ||
(main !== null && main.scrollWidth > main.clientWidth + 1);
if (!overflows) return report;
const describe = (el: Element): string => {
const parts = [el.tagName.toLowerCase()];
if (el.id) parts.push(`#${el.id}`);
const slot = el.getAttribute("data-slot");
if (slot) parts.push(`[data-slot="${slot}"]`);
const cls = (el.getAttribute("class") ?? "").trim();
if (cls) parts.push(`.${cls.split(/\s+/).slice(0, 10).join(".")}`);
return parts.join("").slice(0, 180);
};
const root = main ?? document.body;
const limit = main
? main.getBoundingClientRect().left + main.clientWidth
: vw;
const scrollLeft = main ? main.scrollLeft : window.scrollX;
// An element whose ancestor (below `root`) scrolls or clips is contained by
// design — the UI standards' "wide content scrolls in its own container".
const absorbedByAncestor = (el: Element): boolean => {
let n = el.parentElement;
while (n && n !== root) {
const ox = getComputedStyle(n).overflowX;
if (
ox === "auto" ||
ox === "scroll" ||
ox === "hidden" ||
ox === "clip"
)
return true;
n = n.parentElement;
}
return false;
};
for (const el of Array.from(root.querySelectorAll("*"))) {
const r = el.getBoundingClientRect();
if (r.width === 0 && r.height === 0) continue;
// Fixed/sticky chrome is positioned against the viewport, not the
// scroll area, so it cannot widen it.
const position = getComputedStyle(el).position;
if (position === "fixed" || position === "sticky") continue;
const right = r.right + scrollLeft;
if (right <= limit + 1) continue;
if (absorbedByAncestor(el)) continue;
const chain: string[] = [];
let n: Element | null = el;
while (n && n !== de) {
chain.unshift(describe(n).slice(0, 70));
n = n.parentElement;
}
report.offenders.push({
selector: describe(el),
rectWidth: Math.round(r.width),
rectRight: Math.round(right),
scrollWidth: el.scrollWidth,
path: chain.slice(-5).join(" > "),
});
}
report.offenders.sort((a, b) => b.rectWidth - a.rectWidth);
report.offenders = report.offenders.slice(0, 5);
return report;
});
}
function formatReport(label: string, report: OverflowReport): string {
const lines = [
`${label}: horizontal overflow at viewport ${report.innerWidth}px`,
` document.scrollWidth = ${report.docScrollWidth} (viewport ${report.innerWidth})`,
` main.scrollWidth = ${report.mainScrollWidth} (client ${report.mainClientWidth})`,
];
if (report.offenders.length === 0) {
lines.push(
" no un-contained offender found — the overflow may come from a scroll container's own content",
);
}
for (const o of report.offenders) {
lines.push(
` • width ${o.rectWidth}px, right edge ${o.rectRight}px — ${o.selector}`,
);
lines.push(` path: ${o.path}`);
}
return lines.join("\n");
}
/**
* Assert the page does not scroll sideways — neither the document nor the
* `AuthShell` `<main>` scroll container.
*/
export async function expectNoHorizontalOverflow(
page: Page,
label: string,
): Promise<void> {
const report = await measureHorizontalOverflow(page);
const message = formatReport(label, report);
expect(report.docScrollWidth, message).toBeLessThanOrEqual(
report.innerWidth + 1,
);
if (report.mainScrollWidth !== null && report.mainClientWidth !== null) {
expect(report.mainScrollWidth, message).toBeLessThanOrEqual(
report.mainClientWidth + 1,
);
}
}
/**
* Settle a route before measuring: let the network go idle, pan the whole
* scroll container so every `next/dynamic` below-the-fold block mounts, then
* return to the top. A block that only mounts on scroll can only overflow once
* it has mounted.
*/
export async function settleForOverflowMeasurement(page: Page): Promise<void> {
await page.waitForLoadState("networkidle").catch(() => {});
await page.evaluate(async () => {
const main = document.querySelector<HTMLElement>("main#main-content");
const el: HTMLElement = main ?? document.documentElement;
const step = el.clientHeight || 800;
for (let y = 0; y < el.scrollHeight; y += step) {
el.scrollTop = y;
await new Promise((r) => setTimeout(r, 100));
}
el.scrollTop = 0;
});
await page.waitForLoadState("networkidle").catch(() => {});
}
+268
View File
@@ -0,0 +1,268 @@
import type { Page } from "@playwright/test";
import {
SECTION_MOBILITY,
SECTION_VITALS,
} from "@/components/insights/derived/use-dashboard-derived";
/**
* A POPULATED Insights overview fixture.
*
* The mobile-overflow guard exists to prove that a page carrying real content
* does not scroll sideways. Against the shared e2e account — which owns no
* measurements — `/insights` resolves to its empty state, and an empty page
* trivially fits any viewport. Every section below therefore gets a body:
* populated wellness scores, a full vitals grid, and the "signals of the day"
* card whose heading carries the long plain-language provenance caption.
*
* The strings are deliberately long. Prose in a heading's trailing `action`
* slot is exactly what widened the page in the first place, and a fixture with
* short labels would not reproduce it.
*/
const NOW = "2026-01-15T08:30:00.000Z";
function provenance(inputs: string[]) {
return {
inputs,
source: "DAY" as const,
windowDays: 30,
computedAt: NOW,
};
}
function coverage() {
return {
requiredInputs: 4,
presentInputs: 4,
historyDays: 90,
missing: [] as string[],
};
}
function confidence() {
return { score: 88, band: "high" as const };
}
function deviation(
type: string,
value: number,
low: number,
high: number,
direction: "above" | "below" | "in",
) {
return {
type,
value,
center: (low + high) / 2,
low,
high,
outside: direction !== "in",
direction,
};
}
/**
* The "signals of the day" payload in its `fired` state — the branch that
* renders `SectionHeading action={<CoincidentProvenance/>}`, i.e. a full
* sentence pinned into the heading row.
*/
const COINCIDENT_FIRED = {
metric: "COINCIDENT_DEVIATION",
status: "ok" as const,
value: {
fired: true,
day: "2026-01-15",
illnessExplained: false,
vitals: [
deviation("RESTING_HEART_RATE", 71, 52, 62, "above"),
deviation("HEART_RATE_VARIABILITY", 38, 45, 78, "below"),
deviation("RESPIRATORY_RATE", 17.4, 12.5, 16.2, "above"),
deviation("OXYGEN_SATURATION", 97, 96, 99, "in"),
deviation("BODY_TEMPERATURE", 36.9, 36.2, 37.1, "in"),
],
contributing: [
deviation("RESTING_HEART_RATE", 71, 52, 62, "above"),
deviation("HEART_RATE_VARIABILITY", 38, 45, 78, "below"),
deviation("RESPIRATORY_RATE", 17.4, 12.5, 16.2, "above"),
],
},
coverage: coverage(),
confidence: confidence(),
provenance: provenance([
"RESTING_HEART_RATE",
"HEART_RATE_VARIABILITY",
"RESPIRATORY_RATE",
]),
reason: null,
};
/** A deterministic sparkline series around `center`. */
function series(center: number, spread: number): number[] {
return Array.from({ length: 30 }, (_, i) =>
Number((center + Math.sin(i / 2.5) * spread).toFixed(2)),
);
}
/**
* A populated `WellnessScoreValue` entry — the exact wire shape the three
* persisted score types share (`RECOVERY_SCORE` / `STRESS_SCORE` /
* `STRAIN_SCORE`).
*/
function wellnessScore(
metric: string,
score: number,
band: "green" | "yellow" | "red",
) {
return {
metric,
status: "ok" as const,
value: {
score,
band,
trendDelta: 3.4,
daysInWindow: 28,
asOf: NOW,
series: series(score, 6),
},
coverage: coverage(),
confidence: confidence(),
provenance: provenance(["SLEEP_DURATION", "RESTING_HEART_RATE"]),
reason: null,
assessment: null,
};
}
/** A populated `VitalsBaselineValue` tile — exact wire shape. */
function vitalsBaseline(type: string, center: number, spread: number) {
return {
metric: "VITALS_BASELINE",
status: "ok" as const,
value: {
type,
center,
low: Number((center - spread).toFixed(2)),
high: Number((center + spread).toFixed(2)),
spread,
sampleDays: 30,
k: 2,
series: series(center, spread / 2),
},
coverage: coverage(),
confidence: confidence(),
provenance: provenance([type]),
reason: null,
};
}
/**
* The honest "not enough history" arm. Metrics whose value shapes this fixture
* does not reproduce exactly are served as `insufficient` rather than as an
* invented payload — that is a real state those tiles handle (they un-mount),
* and a wrong-shaped `ok` payload would throw in the tile instead of testing
* anything.
*/
function insufficient(metric: string) {
return {
metric,
status: "insufficient" as const,
value: null,
coverage: {
requiredInputs: 4,
presentInputs: 1,
historyDays: 3,
missing: ["SLEEP_DURATION"],
},
confidence: null,
provenance: provenance([]),
reason: "not_enough_history",
};
}
/** Center + spread per vital, in each metric's own units. */
const VITAL_BANDS: Record<string, [number, number]> = {
RESTING_HEART_RATE: [57, 5],
RESPIRATORY_RATE: [14.1, 1.8],
OXYGEN_SATURATION: [97, 1.5],
BODY_TEMPERATURE: [36.6, 0.45],
BLOOD_GLUCOSE: [92, 13],
WEIGHT: [79.4, 3],
};
function buildBatchMetrics(): Record<string, unknown> {
const metrics: Record<string, unknown> = {
RECOVERY_SCORE: wellnessScore("RECOVERY_SCORE", 66, "yellow"),
STRESS_SCORE: wellnessScore("STRESS_SCORE", 41, "green"),
STRAIN_SCORE: wellnessScore("STRAIN_SCORE", 72, "yellow"),
READINESS: insufficient("READINESS"),
SLEEP_SCORE: insufficient("SLEEP_SCORE"),
FITNESS_AGE: insufficient("FITNESS_AGE"),
VASCULAR_AGE_DELTA: insufficient("VASCULAR_AGE_DELTA"),
HRV_BALANCE: insufficient("HRV_BALANCE"),
BMI: insufficient("BMI"),
SIX_MINUTE_WALK_BAND: insufficient("SIX_MINUTE_WALK_BAND"),
};
for (const type of SECTION_VITALS) {
if (type === "HEART_RATE_VARIABILITY") continue;
const [center, spread] = VITAL_BANDS[type] ?? [50, 5];
metrics[`VITALS_BASELINE:${type}`] = vitalsBaseline(type, center, spread);
}
for (const { metric } of SECTION_MOBILITY) {
metrics[metric] = insufficient(metric);
}
return metrics;
}
/**
* Route-mock a populated Insights overview onto `page`.
*
* Only the reads that decide whether a section renders AT ALL are stubbed;
* everything else (the i18n bundle, every layout decision, the real component
* tree) stays authentic, so the geometry under test is the shipped geometry.
*/
export async function mockPopulatedInsights(page: Page): Promise<void> {
// Without a non-empty comprehensive payload the page short-circuits to its
// "no data yet" empty state and no section mounts at all.
// RegExp matchers throughout, not globs: Playwright's URL glob treats `?` as
// a single-character wildcard, so `**/api/insights/derived?*` also matches
// `/api/insights/derived/batch?…` and the two handlers shadow each other.
await page.route(/\/api\/insights\/comprehensive(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
totalMeasurements: 4821,
moodSummary: { count: 96 },
medications: [{ id: "med-1" }, { id: "med-2" }, { id: "med-3" }],
},
error: null,
}),
}),
);
// The batched derived read behind the wellness strip + the vitals grid.
await page.route(/\/api\/insights\/derived\/batch(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { metrics: buildBatchMetrics() },
error: null,
}),
}),
);
// The single-metric derived route. Only COINCIDENT_DEVIATION is stubbed —
// it is the card whose heading carries the provenance sentence. Any other
// metric falls through to the real handler.
await page.route(/\/api\/insights\/derived\?/, async (route) => {
const metric = new URL(route.request().url()).searchParams.get("metric");
if (metric !== "COINCIDENT_DEVIATION") return route.fallback();
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: COINCIDENT_FIRED, error: null }),
});
});
}
+39
View File
@@ -0,0 +1,39 @@
import { expect, type Locator, type Page } from "@playwright/test";
/**
* Gate a one-shot DOM read on the element it is about to measure.
*
* The failure this exists to prevent: a spec navigates with
* `waitUntil: "networkidle"`, then reads the DOM in a single
* `page.evaluate` / `evaluateAll` that does not retry. An idle network says
* nothing about whether React has rendered, so the read can land on a tree
* that is still mounting and the assertion then blames the application for
* something the test raced — "language + dob fields must exist" when the
* fields were simply not there yet, or a measurement helper accused of
* measuring the wrong element when it was measuring the right one too early.
*
* Two such tests went red on consecutive CI runs during v1.33.0. Neither
* reproduces locally: a developer machine is fast enough that the render
* always wins the race, so the only place this shows up is a loaded runner,
* and `failOnFlakyTests` means every occurrence costs a whole red pipeline
* even when the retry passes.
*
* `sentinel` must be something the measurement actually depends on, not just
* any element on the page. Gating on a layout shell that renders before the
* data proves nothing.
*/
export async function settleBeforeMeasure(
page: Page,
sentinel: Locator,
options: { attachedOnly?: boolean } = {},
): Promise<void> {
if (options.attachedOnly) {
await expect(sentinel).toBeAttached();
} else {
await expect(sentinel).toBeVisible();
}
// Fonts and late CSS change geometry after the element is visible, and
// every measurement in this suite is geometric.
await page.waitForLoadState("networkidle").catch(() => {});
await page.evaluate(() => document.fonts?.ready);
}
@@ -0,0 +1,151 @@
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { mockPopulatedInsights } from "./utils/mock-populated-insights";
import { settleBeforeMeasure } from "./utils/settle";
test.describe("v1.34.1 navigation geometry", () => {
test.use({ storageState: STORAGE_STATE_PATH });
for (const viewport of [
{ name: "desktop", width: 1440, height: 900 },
{ name: "short desktop", width: 1280, height: 600 },
{ name: "tablet", width: 900, height: 700 },
]) {
for (const route of ["/settings/account", "/admin/system-status"]) {
test(`${route} aligns and pins its sidebar at ${viewport.name}`, async ({
page,
}) => {
await page.setViewportSize(viewport);
await page.goto(route, { waitUntil: "domcontentloaded" });
const heading = page.getByRole("heading", { level: 1 });
const sidebar = page.locator("aside").first();
await settleBeforeMeasure(page, heading);
await expect(sidebar).toBeVisible();
const before = await page.evaluate(() => {
const main = document.getElementById("main-content");
const heading = main?.querySelector("h1");
const aside = main?.querySelector("aside");
const wrapper = main?.firstElementChild;
if (!main || !heading || !aside || !wrapper) return null;
return {
headingTop: heading.getBoundingClientRect().top,
asideTop: aside.getBoundingClientRect().top,
wrapperLeft: wrapper.getBoundingClientRect().left,
mainWidth: main.clientWidth,
documentScrollable:
document.documentElement.scrollHeight >
document.documentElement.clientHeight + 1,
};
});
expect(before).not.toBeNull();
if (!before) return;
expect(
Math.abs(before.asideTop - before.headingTop),
).toBeLessThanOrEqual(2);
expect(before.documentScrollable).toBe(false);
await page.locator("#main-content").evaluate((main) => {
main.scrollTop = Math.min(320, main.scrollHeight - main.clientHeight);
});
await page.waitForFunction(() => {
const main = document.getElementById("main-content");
return (
!main ||
main.scrollTop > 0 ||
main.scrollHeight <= main.clientHeight
);
});
const after = await page.evaluate(() => {
const main = document.getElementById("main-content");
const aside = main?.querySelector("aside");
const wrapper = main?.firstElementChild;
if (!main || !aside || !wrapper) return null;
return {
asideTop: aside.getBoundingClientRect().top,
wrapperLeft: wrapper.getBoundingClientRect().left,
mainWidth: main.clientWidth,
};
});
expect(after).not.toBeNull();
if (!after) return;
expect(Math.abs(after.asideTop - before.asideTop)).toBeLessThanOrEqual(
2,
);
expect(after.wrapperLeft).toBe(before.wrapperLeft);
expect(after.mainWidth).toBe(before.mainWidth);
});
}
}
});
test.describe("v1.34.1 mobile Insights pill row", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("has no unexplained gap above the row and preserves focus, safe bounds, and 44px targets", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
await mockPopulatedInsights(page);
await page.goto("/insights", { waitUntil: "domcontentloaded" });
const firstPill = page
.locator(
'[data-slot="insights-tab-strip-pill"], [data-slot="insights-tab-strip-group"]',
)
.first();
await settleBeforeMeasure(page, firstPill);
const geometry = await page.evaluate(() => {
const main = document.getElementById("main-content");
const strip = document.querySelector<HTMLElement>(
'[data-slot="insights-tab-strip"]',
);
const pill = document.querySelector<HTMLElement>(
'[data-slot="insights-tab-strip-pill"], [data-slot="insights-tab-strip-group"]',
);
if (!main || !strip || !pill) return null;
const mainRect = main.getBoundingClientRect();
const stripRect = strip.getBoundingClientRect();
const pillRect = pill.getBoundingClientRect();
return {
gapAbove: stripRect.top - mainRect.top,
stripLeft: stripRect.left,
stripRight: stripRect.right,
mainLeft: mainRect.left,
mainRight: mainRect.right,
pillHeight: pillRect.height,
mainOverflows: main.scrollWidth > main.clientWidth + 1,
documentOverflows:
document.documentElement.scrollWidth >
document.documentElement.clientWidth + 1,
};
});
expect(geometry).not.toBeNull();
if (!geometry) return;
expect(geometry.gapAbove).toBeLessThanOrEqual(4);
expect(geometry.stripLeft).toBeGreaterThanOrEqual(geometry.mainLeft);
expect(geometry.stripRight).toBeLessThanOrEqual(geometry.mainRight);
expect(geometry.pillHeight).toBeGreaterThanOrEqual(44);
expect(geometry.mainOverflows).toBe(false);
expect(geometry.documentOverflows).toBe(false);
await firstPill.focus();
await expect(firstPill).toBeFocused();
expect(
await firstPill.evaluate((element) => {
const style = getComputedStyle(element);
return (
element.matches(":focus-visible") &&
(style.outlineStyle !== "none" || style.boxShadow !== "none")
);
}),
).toBe(true);
});
});
+158
View File
@@ -0,0 +1,158 @@
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import type { Page, TestInfo } from "@playwright/test";
import type { DailyDigest } from "@/lib/daily/digest";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
LONG_HEADLINE_BRIEFING,
mockDashboardSnapshot,
} from "./utils/mock-dashboard-snapshot";
import { mockPopulatedInsights } from "./utils/mock-populated-insights";
import { settleBeforeMeasure } from "./utils/settle";
const EVIDENCE_DIR = join(process.cwd(), "test-results", "v1341");
const NARRATIVE_DIGEST: DailyDigest = {
generatedAt: "2026-07-29T08:30:00.000Z",
phase: "final",
sleepPending: false,
score: {
value: 84,
band: "green",
delta: -2,
deltaReason: null,
scoreVersion: 2,
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
},
topSignal: LONG_HEADLINE_BRIEFING.signalsOfDay?.[0] ?? null,
briefingLead: LONG_HEADLINE_BRIEFING.paragraph,
line: LONG_HEADLINE_BRIEFING.paragraph,
worthALook: [],
justIn: null,
reactionLine: null,
};
async function settleAndCapture(
page: Page,
testInfo: TestInfo,
filename: string,
): Promise<void> {
await settleBeforeMeasure(page, page.locator("#main-content"));
await page.emulateMedia({ reducedMotion: "reduce" });
await page.addStyleTag({
content:
"*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}",
});
await page.evaluate(() => document.fonts?.ready);
const path = join(EVIDENCE_DIR, filename);
await page.screenshot({ path, animations: "disabled" });
await testInfo.attach(filename, { path, contentType: "image/png" });
}
test.describe("v1.34.1 deterministic UI evidence", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(({}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"one canonical capture per named evidence file",
);
});
test("captures the five release-review surfaces", async ({
page,
}, testInfo) => {
await mkdir(EVIDENCE_DIR, { recursive: true });
await page.setViewportSize({ width: 1440, height: 900 });
let dailyDigest = NARRATIVE_DIGEST;
// TodayHero reads the unified digest endpoint, not the dashboard
// snapshot's legacy briefing field. Keep a mutable, deterministic reader
// so this evidence covers both the narrative composition captured below
// and the compact score-only fallback through the exact production seam.
await page.route(/\/api\/daily\/digest(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: dailyDigest, error: null }),
}),
);
await mockDashboardSnapshot(page, {
briefing: LONG_HEADLINE_BRIEFING,
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.locator("#main-content")).toBeVisible();
const hero = page.locator('[data-slot="today-hero"]');
await expect(hero).toHaveAttribute("data-layout", "narrative");
await expect(hero.locator('[data-slot="today-hero-lead"]')).toContainText(
"Dein systolischer Blutdruck",
);
await expect(hero.locator('[data-slot="score-ring"]')).toHaveCSS(
"width",
"168px",
);
await settleAndCapture(page, testInfo, "dashboard-desktop.png");
dailyDigest = {
...NARRATIVE_DIGEST,
topSignal: null,
briefingLead: null,
line: "Dein Gesundheitsscore liegt heute bei 84.",
reactionLine: null,
};
await page.reload({ waitUntil: "domcontentloaded" });
await expect(hero).toHaveAttribute("data-layout", "compact-all-clear");
await expect(hero.locator('[data-slot="today-hero-lead"]')).toHaveCount(0);
await expect(
hero.locator('[data-slot="today-hero-all-clear"]'),
).toBeVisible();
await expect(hero.locator('[data-slot="score-ring"]')).toHaveCSS(
"width",
"120px",
);
await expect(
hero.locator('[data-slot="score-ring"]').getByText("84", { exact: true }),
).toHaveCount(1);
await settleAndCapture(page, testInfo, "dashboard-score-only-desktop.png");
await page.setViewportSize({ width: 390, height: 844 });
const compactHeroBox = await hero.boundingBox();
expect(compactHeroBox).not.toBeNull();
expect(
compactHeroBox?.width ?? Number.POSITIVE_INFINITY,
).toBeLessThanOrEqual(390);
expect(
await page.evaluate(
() =>
document.documentElement.scrollWidth <=
document.documentElement.clientWidth,
),
).toBe(true);
await mockPopulatedInsights(page);
await page.goto("/insights", { waitUntil: "domcontentloaded" });
await expect(
page.locator('[data-slot="insights-tab-strip"]'),
).toBeVisible();
await settleAndCapture(page, testInfo, "insights-mobile.png");
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/settings/anamnesis", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
await settleAndCapture(page, testInfo, "anamnesis-desktop.png");
await page.setViewportSize({ width: 1280, height: 600 });
await page.goto("/settings/account", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
await settleAndCapture(page, testInfo, "settings-short-desktop.png");
await page.setViewportSize({ width: 900, height: 800 });
await page.goto("/admin/system-status", {
waitUntil: "domcontentloaded",
});
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
await settleAndCapture(page, testInfo, "admin-tablet.png");
});
});
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+41 -34
View File
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.27 MB6 — public surfaces.
@@ -8,10 +9,9 @@ import { expect, test } from "@playwright/test";
* 1. `/about` — added to `PUBLIC_PATHS` in `src/proxy.ts` for the
* GeoLite2 CC BY-SA 4.0 attribution. A logged-out visitor must
* load the page (HTTP 200) and see the attribution copy.
* 2. `/this-route-does-not-exist` — Next.js renders
* `src/app/not-found.tsx`, a branded 404 page with the logo, a
* headline, and a "Back to dashboard" link. The proxy must NOT
* bounce the missing route to `/auth/login`.
* 2. `/this-route-does-not-exist` — the proxy keeps an anonymous visitor
* behind the login boundary, while an authenticated visitor reaches
* Next.js's branded `src/app/not-found.tsx`.
* 3. `/privacy` — the page mounts a collapsible `<details>` TOC
* (`data-slot="privacy-toc"`); clicking the summary opens it
* and the anchor links inside navigate to the right section.
@@ -24,11 +24,7 @@ test.describe("v1.4.27 — public pages", () => {
// Force no storage state so we run as an anonymous visitor.
test.use({ storageState: { cookies: [], origins: [] } });
// v1.4.28 backlog: selectors below describe the intent (about page
// heading, GeoLite2 attribution copy, MaxMind reference, CC BY-SA
// text) but do not yet match the actual rendered DOM. Re-implement
// against the live page once the surface is verified.
test.fixme("/about returns 200 and renders the GeoLite2 attribution", async ({
test("/about returns 200 and renders the GeoLite2 attribution", async ({
page,
}) => {
const response = await page.goto("/about", {
@@ -43,42 +39,26 @@ test.describe("v1.4.27 — public pages", () => {
).toBeVisible();
// GeoLite2 attribution is the load-bearing reason this page exists.
await expect(page.getByText(/GeoLite2/)).toBeVisible();
await expect(page.getByText(/MaxMind/)).toBeVisible();
await expect(page.getByText(/GeoLite2/).first()).toBeVisible();
await expect(page.getByText(/MaxMind/).first()).toBeVisible();
await expect(page.getByText(/Attribution-ShareAlike 4\.0/i)).toBeVisible();
});
// v1.4.28 backlog: Next.js's `not-found.tsx` may return 200 or 404
// depending on rendering pipeline; the branded body selectors below
// need a sanity pass against the actual page output. Re-enable once
// the contract is verified.
test.fixme("/this-route-does-not-exist renders the branded 404 page", async ({
test("/this-route-does-not-exist keeps an anonymous visitor behind login", async ({
page,
}) => {
const response = await page.goto("/this-route-does-not-exist", {
waitUntil: "domcontentloaded",
});
// Next.js returns 404 for `not-found.tsx` — the page must render
// its own body, not redirect to `/auth/login`.
expect(response?.status()).toBe(404);
expect(page.url()).not.toMatch(/\/auth\/login/);
// Branded splash: small "404" eyebrow + headline + back link.
await expect(page.getByText(/^404$/i)).toBeVisible();
// `page.goto` follows the proxy's 307 and returns the final login response.
expect(response?.status()).toBe(200);
await expect(page).toHaveURL(/\/auth\/login(?:\?|$)/);
await expect(
page.getByRole("heading", { name: /page not found/i }),
page.getByRole("button", { name: /sign in with passkey/i }),
).toBeVisible();
const back = page.getByRole("link", { name: /back to dashboard/i });
await expect(back).toBeVisible();
await expect(back).toHaveAttribute("href", "/");
});
// v1.4.28 backlog: the privacy TOC `<details>` slot selector
// does not yet match what MB6 actually shipped. Re-implement once
// the data-slot attribute and anchor targets are verified against
// the live page.
test.fixme("/privacy renders the TOC and the anchor links jump to sections", async ({
test("/privacy renders the TOC and the anchor links jump to sections", async ({
page,
}) => {
const response = await page.goto("/privacy", {
@@ -121,3 +101,30 @@ test.describe("v1.4.27 — public pages", () => {
expect(introTop).toBeLessThan(800);
});
});
test.describe("v1.4.27 — authenticated not-found page", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("/this-route-does-not-exist renders the branded 404 page", async ({
page,
}) => {
const response = await page.goto("/this-route-does-not-exist", {
waitUntil: "domcontentloaded",
});
expect(response?.status()).toBe(404);
expect(page.url()).not.toMatch(/\/auth\/login/);
await expect(page.getByText(/^404$/i)).toBeVisible();
await expect(
page.getByRole("heading", {
name: /this page doesn't exist|diese seite existiert nicht/i,
}),
).toBeVisible();
const back = page.getByRole("link", {
name: /back to dashboard|zurück zum dashboard/i,
});
await expect(back).toBeVisible();
await expect(back).toHaveAttribute("href", "/");
});
});
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
/**
* /api/version is the public, unauthenticated endpoint that drives
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
@@ -0,0 +1,67 @@
/**
* Unit tests for the `healthlog/job-handler-outcome` rule — the bare
* `boss.work(` ban, the one exempt module that owns `createAndWork`, the
* test-file exemption, and the scope limit to `src/lib/jobs/`.
*
* The `invalid` cases below also stand as the rule's own documented blind
* spots: an aliased or renamed receiver is not detected, and neither is a
* handler that returns `{ ok: true }` after swallowing. Those sit in `valid`
* on purpose, so a future reader can see what this rule does not claim.
*/
import { describe, it } from "vitest";
import { RuleTester } from "eslint";
import rule from "../job-handler-outcome.js";
RuleTester.describe = describe;
RuleTester.it = it;
RuleTester.itOnly = it.only;
const ruleTester = new RuleTester({
languageOptions: { ecmaVersion: 2023, sourceType: "module" },
});
ruleTester.run("job-handler-outcome", rule, {
valid: [
{
code: "await createAndWork(boss, QUEUE, { localConcurrency: 1 }, handleX);",
filename: "/repo/src/lib/jobs/reminder/register-status.ts",
},
{
// The wrapper's own call — this module is the one legal caller.
code: "await boss.work(queue, options, runJob(queue, handler));",
filename: "/repo/src/lib/jobs/reminder/registrar-shared.ts",
},
{
// Tests mock `boss.work` and assert against it directly.
code: 'boss.work("q", {}, async () => {});',
filename: "/repo/src/lib/jobs/__tests__/whoop-queues.test.ts",
},
{
// Out of scope: not the worker tree.
code: 'boss.work("q", {}, async () => {});',
filename: "/repo/src/lib/integrations/status.ts",
},
{
// Documented blind spot: an aliased receiver is not detected.
code: 'const b = boss; b.work("q", {}, async () => {});',
filename: "/repo/src/lib/jobs/reminder/register-rollup.ts",
},
{
// Documented blind spot: this rule cannot see a swallowed failure.
code: "await createAndWork(boss, QUEUE, {}, async () => ({ ok: true, did: {} }));",
filename: "/repo/src/lib/jobs/reminder/register-rollup.ts",
},
],
invalid: [
{
code: 'await boss.work("scratch", async () => {});',
filename: "/repo/src/lib/jobs/reminder/register-maintenance.ts",
errors: [{ messageId: "bareBossWork" }],
},
{
code: "await boss.work(QUEUE, { localConcurrency: 1 }, handleX);",
filename: "/repo/src/lib/jobs/worker-status.ts",
errors: [{ messageId: "bareBossWork" }],
},
],
});
Loaded 100 of 1238 files, more files were not shown because too many files have changed in this diff. Show more