Compare commits

..
642 Commits
Author SHA1 Message Date
Marc-André Bombeck 59f910df5e Merge pull request #1234 from MBombeck/fix/v1432-consent
Release v1.43.2
2026-10-16 17:51:43 +02:00
Marc-André Bombeck 1a2853395b chore(release): v1.43.2 2026-10-16 17:11:58 +02:00
Marc-André Bombeck e2b7e7350e fix(consent): mint AI consent receipts only on an explicit agreement
The AI settings page posted to /api/consent/ai/web on every mount and the
server minted an ai_full receipt for it, so an account that had agreed to
nothing sent its health data to the operator's provider from background
jobs. Native builds synced a receipt the same way on every launch.

- Both grant routes now require `confirm: true`; anything else writes
  nothing and answers 400 consent.ai.confirmRequired.
- Remove the mount-time heal. A consent card names the operator's service
  and the data that would go there, with one Agree button, in Settings > AI
  and on the Insights overview while AI is closed by the missing consent
  alone.
- Publish the standing consent as `ai.consent` on /api/auth/me (status,
  since, recipients); the settings card renders active-since and the
  withdraw control from it.
- Existing receipts stay valid.
- Guard that no client grants outside a click handler, route tests for the
  confirmation, and an integration test that a fresh operator-key account
  gets no background AI text until it agrees.
2026-10-16 17:11:04 +02:00
Marc-André Bombeck bc5ea5a67b Merge pull request #1233 from MBombeck/fix/v1431
Release v1.43.1
2026-10-16 11:40:20 +02:00
Marc-André Bombeck d2fe55bc9d chore(release): v1.43.1 2026-10-16 11:01:44 +02:00
Marc-André Bombeck 2fb0ebfbf6 Merge remote-tracking branch 'origin/main' into fix/v1431 2026-10-16 11:01:29 +02:00
Marc-André Bombeck 0ee7e156d3 fix(report): print lab values with the precision they were recorded at
The doctor report PDF printed lab values and reference ranges with one
decimal and the share page with two at most, so a PSA of 0.034 read 0.0
or 0.03 and a TSH window of 0.27 read 0.3, while the labs page kept more.
The labs page itself cut values above 1 to two decimals.

One rule now decides every recorded number (src/lib/format/recorded-number):
a stored value keeps every digit it holds, minus binary float noise, and a
value converted from the unit its report printed keeps the precision of the
printed figure. The report payload carries sourceValue for that. The labs
page, its chart and history, the PDF lab table and footnote, the share
page, the day view and the OCR review all print through it, reference
bounds included. Doses on the PDF and the share page follow the same rule.

Measurement statistics in the PDF and on the share page now share one
per-row precision: up to three significant digits but never more decimals
than recorded, so a waist-to-height ratio of 0.52 no longer reads 0.5;
glucose keeps its unit's precision.

Refs #1228
2026-10-16 11:00:27 +02:00
Marc-André Bombeck f3c4fbff2c fix(illness): keep every episode reachable from the illness page
A recurring bout filed under its condition nests inside that condition's
card, but the list view drew no nested episodes, so an ended recurring
bout of an ended condition had no row anywhere and was found only through
search. A bout nested under an already nested parent was lost in either
view, and the page read only the list route's first page of 50, so the
oldest episodes dropped off the page, its settings and every picker.

Grouping moves into groupEpisodes: each episode nests under the highest
ancestor that shares its status, or is a card of its own, and a looping
parent chain leaves every member a card. Both views render the nested
episodes with their course and end date; the count heading says "flares"
only when they all are. useIllnessEpisodes reads page after page until a
short one.

Refs #1224
2026-10-16 11:00:26 +02:00
dependabot[bot] abc8ebd5c4 build(deps): bump the production-weekly group with 10 updates (#1231)
Bumps the production-weekly group with 10 updates:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1147.0` | `3.1150.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.1147.0` | `3.1150.0` |
| [@napi-rs/canvas](https://github.com/Brooooooklyn/canvas) | `1.0.10` | `1.0.11` |
| [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) | `5.104.1` | `5.104.2` |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.13` | `3.14.17` |
| [dotenv](https://github.com/motdotla/dotenv) | `18.0.6` | `18.0.7` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.53.0` | `1.56.0` |
| [nodemailer](https://github.com/nodemailer/nodemailer) | `10.0.16` | `10.1.0` |
| [radix-ui](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radix-ui) | `1.7.0` | `1.8.1` |
| [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.89.0` | `7.90.0` |


Updates `@aws-sdk/client-s3` from 3.1147.0 to 3.1150.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1150.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.1147.0 to 3.1150.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1150.0/lib/lib-storage)

Updates `@napi-rs/canvas` from 1.0.10 to 1.0.11
- [Release notes](https://github.com/Brooooooklyn/canvas/releases)
- [Changelog](https://github.com/Brooooooklyn/canvas/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Brooooooklyn/canvas/compare/v1.0.10...v1.0.11)

Updates `@tanstack/react-query` from 5.104.1 to 5.104.2
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.104.2/packages/react-query)

Updates `@tanstack/react-virtual` from 3.14.13 to 3.14.17
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.17/packages/react-virtual)

Updates `dotenv` from 18.0.6 to 18.0.7
- [Changelog](https://github.com/motdotla/dotenv/blob/master/CHANGELOG.md)
- [Commits](https://github.com/motdotla/dotenv/compare/v18.0.6...v18.0.7)

Updates `lucide-react` from 1.53.0 to 1.56.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.56.0/packages/lucide-react)

Updates `nodemailer` from 10.0.16 to 10.1.0
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v10.0.16...v10.1.0)

Updates `radix-ui` from 1.7.0 to 1.8.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radix-ui/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/radix-ui)

Updates `react-hook-form` from 7.89.0 to 7.90.0
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.89.0...v7.90.0)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1150.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.1150.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
- dependency-name: "@napi-rs/canvas"
  dependency-version: 1.0.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-weekly
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.104.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-weekly
- dependency-name: "@tanstack/react-virtual"
  dependency-version: 3.14.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-weekly
- dependency-name: dotenv
  dependency-version: 18.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-weekly
- dependency-name: lucide-react
  dependency-version: 1.56.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
- dependency-name: nodemailer
  dependency-version: 10.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
- dependency-name: radix-ui
  dependency-version: 1.8.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
- dependency-name: react-hook-form
  dependency-version: 7.90.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-16 08:37:24 +00:00
dependabot[bot] 8c2222ccba build(deps): bump ossf/scorecard-action from 2.4.4 to 2.5.0 (#1230)
Bumps [ossf/scorecard-action](https://github.com/ossf/scorecard-action) from 2.4.4 to 2.5.0.
- [Release notes](https://github.com/ossf/scorecard-action/releases)
- [Changelog](https://github.com/ossf/scorecard-action/blob/main/RELEASE.md)
- [Commits](https://github.com/ossf/scorecard-action/compare/2d1146689b8cda280b9bc96326124645441f03bc...fa7117db83c52d787ae5a89f350999701505d78e)

---
updated-dependencies:
- dependency-name: ossf/scorecard-action
  dependency-version: 2.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-16 08:22:50 +00:00
dependabot[bot] cdd5cbdb61 build(deps): bump actions/download-artifact from 8.0.1 to 8.0.2 (#1229)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 8.0.1 to 8.0.2.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c...9000827ccba6bdab643e8b6fd33ac0654aef8333)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: 8.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-16 08:10:18 +00:00
Marc-André Bombeck 2f5c4659fd Merge pull request #1227 from MBombeck/release/v1.43.0
Release v1.43.0
2026-10-16 09:25:57 +02:00
Marc-André Bombeck d06ce36049 chore(release): v1.43.0 2026-10-16 08:46:13 +02:00
Marc-André Bombeck f543680ada fix(settings): keep the German nutrient card description within the length guideline 2026-10-16 08:42:43 +02:00
Marc-André Bombeck fb80b4bc62 fix(settings): say that the nutrient card's totals include the supplements taken 2026-10-16 08:42:13 +02:00
Marc-André Bombeck 53c53ccd76 Merge branch 'v143/ui390' into release/v1.43.0 2026-10-16 08:41:32 +02:00
Marc-André Bombeck 2f9628aa95 fix(links): name a related dose by its vaccine, not its catalogue slug 2026-10-16 08:32:49 +02:00
Marc-André Bombeck 27b173eb26 Merge branch 'release/v1.43.0' into v143/ui390 2026-10-16 08:03:29 +02:00
Marc-André Bombeck 69e2f18788 fix(ui): tidy the new record and settings surfaces at phone width
- Composition amounts show only the decimals they carry, so 100 µg no
  longer reads as 100.000 µg; the remove button stays on the amount row.
- AUDIT-C remove controls are icon-only, so they no longer spill out of
  the card or cover the earlier answer.
- Life-circumstance labels keep their colon when they wrap, checkbox
  groups sit in two columns and the weekly-minutes sentence ends.
- Link chips get a 44 px hit area for their remove control.
- The AI usage level reports a failed save with a sentence and the
  error id instead of a bare "Error".
- The Dawarich home-country and admin refresh-gap labels sit above
  their fields.
2026-10-16 08:03:29 +02:00
Marc-André Bombeck 0de5a3e5df docs(changelog): describe the merge preview as it works 2026-10-16 07:14:11 +02:00
Marc-André Bombeck b31b81fec6 docs: name the new sync-gap variable, the merge preview and the Dawarich schedule zone 2026-10-16 07:13:57 +02:00
Marc-André Bombeck 70de08792e docs(changelog): add v1.43.0 2026-10-16 07:01:36 +02:00
Marc-André Bombeck c6d57e7ccf Merge branch 'v143/bundle' into release/v1.43.0 2026-10-16 06:59:53 +02:00
Marc-André Bombeck 057fd3b49c perf(bundle): keep Zod off the first load of four eager routes
zod 4.6 grew the shared Zod chunk from 63 to 89 KB gz (the new schema
compiler and its memoizer ride along because every schema goes through
the `z` namespace), and the dashboard, measurements, mood and settings
routes each carried it for values that need no validation.

Move the measurement source list, the notes limit, the screening check
and the Coach source list into value-only modules the schemas are built
from, import the settings classifier and response check from their
leaves, and load the gate's managed settings and anamnesis sections on
demand. Re-base the four route baselines to the measured values and the
aggregate cap to 4975, with the reason in the budget comment.
2026-10-16 06:58:48 +02:00
Marc-André Bombeck a806676a71 Merge branch 'v143/fixdata' into release/v1.43.0 2026-10-16 06:22:02 +02:00
Marc-André Bombeck 852433255c test: follow the newest-entry read and the pulse card toast count 2026-10-16 06:05:08 +02:00
Marc-André Bombeck d3f6d2d7c7 Merge branch 'v143/fixui' into release/v1.43.0 2026-10-16 06:04:00 +02:00
Marc-André Bombeck f715190703 Merge branch 'release/v1.43.0' into v143/fixdata 2026-10-16 05:57:22 +02:00
Marc-André Bombeck 617b87a583 fix(measurements): sum both starts of the repeated fall-back hour into its slot 2026-10-16 05:57:19 +02:00
Marc-André Bombeck f360d221e1 fix(admin): record the pulse rebuild and let a stopped one run again
The rebuild after apply now writes its state beside the dry run
(queued, running, done with counts, failed); a run left queued or
running without a job reads as failed. The card shows it and offers a
new rebuild after a failure, which the route accepts once the method is
applied, without a new dry run.
2026-10-16 05:55:31 +02:00
Marc-André Bombeck beb7ef4ccc Merge branch 'release/v1.43.0' into v143/fixui 2026-10-16 05:55:14 +02:00
Marc-André Bombeck d6ea1c7973 Merge branch 'v143/loose' into release/v1.43.0 2026-10-16 05:50:52 +02:00
Marc-André Bombeck cc9d523bc8 fix(nutrients): recompute supplement rows when the module returns or the zone moves 2026-10-16 05:49:58 +02:00
Marc-André Bombeck abbb424d21 fix(targets): read the seven-day basis as one aggregate instead of the whole summary slice 2026-10-16 05:49:51 +02:00
Marc-André Bombeck 37645fc4bb fix(measurements): cut drain days in the zone the server publishes for the account 2026-10-16 05:48:13 +02:00
Marc-André Bombeck 527d177371 test(mood): keep the random request id out of the no-leak byte check 2026-10-16 05:48:00 +02:00
Marc-André Bombeck d2a806364b test(mcp): accept type-array unions in the strict output-schema check 2026-10-16 05:48:00 +02:00
Marc-André Bombeck 3c44f3d167 fix(auth): ask the provider for a fresh sign-in when claiming a profile 2026-10-16 05:45:59 +02:00
Marc-André Bombeck 23f008c8bb fix(anamnesis): serialise the AI switch writes so a PUT cannot resurrect a section 2026-10-16 05:45:06 +02:00
Marc-André Bombeck 13c74c4e92 test(targets): pin the resting pulse verdict below a default band as hold 2026-10-16 05:44:17 +02:00
Marc-André Bombeck 8ae60e2cb3 fix(coach): read free weekdays for a prompt only with the work section released 2026-10-16 05:42:15 +02:00
Marc-André Bombeck 120b0966b1 fix(ai-usage): never regenerate more often on frugal than on standard
The frugal sync gap is the larger of a day and the operator's sync gap,
so an instance gap above 24 hours no longer leaves frugal ahead of
standard.
2026-10-16 05:38:36 +02:00
Marc-André Bombeck a8b46b9ac0 docs(api): regenerate the OpenAPI contract 2026-10-16 05:37:18 +02:00
Marc-André Bombeck b99b70127d fix(anamnesis): one newest AUDIT-C entry, scored against the wording offered
The stored language is the wording the read route offers the account;
a request naming another wording, or a takenAt more than five minutes
ahead, is refused. Latest, offerDue, the Coach block and the doctor
report all read the newest entry whose answers open.

The old one-word alcohol pattern gives way to an AUDIT-C entry in the
Coach block, and the alcohol section lists it with a remove action.
2026-10-16 05:36:42 +02:00
Marc-André Bombeck 2fc74e76df fix(targets): place the target verdict on the dashboard's seven-day basis 2026-10-16 05:35:52 +02:00
Marc-André Bombeck 6776decaa0 test(guard): discover the resolved fields and demand a reader or a stated reason for each 2026-10-16 05:35:48 +02:00
Marc-André Bombeck da1d31e370 feat(labs): show and file a reading's related report and visit in its sheet 2026-10-16 05:34:08 +02:00
Marc-André Bombeck 7167ba8cdc fix(labs): pair the panel changes by marker, not by printed name 2026-10-16 05:32:13 +02:00
Marc-André Bombeck 8ba4d1b0d0 fix(nutrients): name the form an upper limit covers and phrase it per locale 2026-10-16 05:31:33 +02:00
Marc-André Bombeck d66d3969cd fix(records): show the screening hints when one applies without a smoking answer 2026-10-16 05:28:53 +02:00
Marc-André Bombeck e9a0b3d959 fix(dawarich): keep a boundary fragment from overwriting a complete day
Dawarich groups the timeline by the day in its own account zone and sums
only what lies inside the window. Reach a whole day past both ends of
each request and keep only the chunk's own dates, so the cut edge days
are discarded and chunks never overwrite each other.
2026-10-16 05:28:51 +02:00
Marc-André Bombeck c777819b33 fix(integrations): bound the Dawarich and Nightscout answers while reading 2026-10-16 05:28:07 +02:00
Marc-André Bombeck 1a7050d95d fix(backup): bring every relation link back from a portable restore
A portable file carries no id for a medication or a lab result, so the
restore mints fresh ones and the links that named the old ids were
dropped as having no end. Carry the source id as ref on both entries and
re-point the links through an old-to-new map on restore.
2026-10-16 05:25:50 +02:00
Marc-André Bombeck b3dcd428f6 fix(settings): stack the AI usage estimate on narrow screens and read readiness from the server 2026-10-16 05:23:59 +02:00
Marc-André Bombeck 86113848b5 fix(medications): refresh the episode and document details after filing a link 2026-10-16 05:23:06 +02:00
Marc-André Bombeck 0cb877e0a2 fix(targets): hold below a default band for load measures and above the band for one-sided measures 2026-10-16 05:22:26 +02:00
Marc-André Bombeck a17f85c050 docs(ops): add the recovery steps for an upgrade stopped at migration 0409 2026-10-16 03:57:46 +02:00
Marc-André Bombeck 1734f149d3 docs(openapi): deprecate the legacy integration status fields 2026-10-16 03:57:46 +02:00
Marc-André Bombeck adf0c7f4c2 refactor(validation): route every ISO instant through isoDateTime 2026-10-16 03:57:46 +02:00
Marc-André Bombeck 92b361c3c6 feat(validation): add isoDateTime with a refusal counter for instants without seconds 2026-10-16 03:57:45 +02:00
Marc-André Bombeck b1c19dcfa4 build(deps): move zod to 4.6.7 and lift the 4.5 hold 2026-10-16 03:44:55 +02:00
Marc-André Bombeck c508ce61e3 chore(bundle): raise the aggregate budget to 4860 on the measured v1.43 build 2026-10-16 03:41:58 +02:00
Marc-André Bombeck 4daad8f2fb Merge branch 'v143/life' into release/v1.43.0
# Conflicts:
#	docs/api/openapi.yaml
#	src/__tests__/contract-stub-inventory.test.ts
#	tests/integration/sharing-manage-handler-matrix.test.ts
2026-10-16 03:39:40 +02:00
Marc-André Bombeck df3136a2a1 feat(anamnesis): announce the life circumstances to clients 2026-10-16 03:27:31 +02:00
Marc-André Bombeck c39dca8fe4 Merge branch 'v143/intfix' into release/v1.43.0 2026-10-16 03:17:11 +02:00
Marc-André Bombeck ed3597adb2 Merge branch 'release/v1.43.0' into v143/intfix 2026-10-16 02:59:48 +02:00
Marc-André Bombeck fc4423c934 test(environment): stop asserting the readable location columns 0409 dropped
The free-text backfill no longer seals home, period or day locations, so its
summary has no counts for them, and a stored day has no readable lat left to
be null.
2026-10-16 02:59:48 +02:00
Marc-André Bombeck 87335d3080 test(managed-profiles): compare the guardian refusals without the request id
Every error envelope now carries a fresh meta.requestId. The unknown-id and
the not-yours refusal still have to match byte for byte on everything else;
compare them through the shared helper, as the other enumeration checks do.
2026-10-16 02:59:48 +02:00
Marc-André Bombeck f5ed62276e test(wipe): seed a column fenced by a CHECK list with a value it admits
The schema-driven seeder wrote a synthetic tag into every required text
column. The v1.43 tables fence some of them with CHECK (col IN (...)), so the
seed was refused before either wipe ran. Read each single-column IN-list check
from pg_constraint and seed its first admitted value.
2026-10-16 02:59:47 +02:00
Marc-André Bombeck 765f6c0e15 test(sharing): count both v1.43 admissions in the manage matrix
The biomarker merge and the supplement composition PUT each brought a strict
driver and an owned effect, and each branch raised the count from 88 to 89 on
its own. Together the inventory holds 90.
2026-10-16 02:59:47 +02:00
Marc-André Bombeck da860aeb3e Merge branch 'v143/dawarich' into release/v1.43.0
# Conflicts:
#	src/__tests__/contract-stub-inventory.test.ts
2026-10-16 02:51:02 +02:00
Marc-André Bombeck 8f59d1c5ab docs(api): describe the date-only valid-since day 2026-10-16 01:40:16 +02:00
Marc-André Bombeck c03f79afd5 test(dawarich): pin the movement block, the channels and the mocks; document the connection
Covers the day section, the model projection without the travel country and
the two correlation channels; the backup and correlation mocks learn the new
delegate. Adds the operator page for DAWARICH_PRIVATE_ORIGINS and regenerates
the API contract.
2026-10-16 01:38:05 +02:00
Marc-André Bombeck 4d2c8c2bbb test(anamnesis): drive the life circumstances, AUDIT-C and its backup through real Postgres and the manage matrix 2026-10-16 01:34:17 +02:00
Marc-André Bombeck 88aa5ccacd test(anamnesis): pin derived values, AUDIT-C wording and cut-offs, and the per-section prompt gate 2026-10-16 01:27:00 +02:00
Marc-André Bombeck 0ba2e36b79 test: compare the target cards without the route's direction verdict, and stop pinning the full feature set 2026-10-16 01:26:15 +02:00
Marc-André Bombeck e5860c9f41 docs(api): regenerate the OpenAPI document for the life circumstances 2026-10-16 01:25:35 +02:00
Marc-André Bombeck 73bb13a982 feat(anamnesis): soft-delete AUDIT-C in its route, admit both AUDIT-C verbs at manage level, and settle the guards 2026-10-16 01:24:56 +02:00
Marc-André Bombeck 3aa2a6fd26 Merge branch 'v143/pulse' into release/v1.43.0 2026-10-16 01:23:33 +02:00
Marc-André Bombeck b99bcaeec6 test(dawarich): cover the daily values, the refusals and the backup ends
A stub Dawarich instance on the loopback interface drives the shipped routes
and the sync against real SQL. Unit tests pin the parsers, the time-away
arithmetic, the origin grant, the travel runs, the places route's no-write
rule, the backup pair and the schema's no-location rule.
2026-10-16 01:23:26 +02:00
Marc-André Bombeck 0a0667fd1c test(admin): pin when the pulse day-mean card offers apply 2026-10-16 01:23:03 +02:00
Marc-André Bombeck b838ed4403 feat(dawarich): fetch daily movement values from a Dawarich instance
Connect, status, places, settings, test, sync and disconnect replace their
stubs. A nightly job (03:20) and the connect backfill read the timeline,
visits and residency endpoints through safeFetch and store one row of daily
values per local day: distances, places visited, minutes away from the home
place and the sealed country with its travel-day verdict. A private instance
needs its exact origin in DAWARICH_PRIVATE_ORIGINS.

The connection joins the shared ledger and the connection health model,
watched for errors only. The day carries a movement block, the model
projection drops the travel country, the two movement channels join the
correlation matrix under the environment module, travel days become trip
suggestions, and the days travel in the environment backup section.
2026-10-16 01:20:53 +02:00
Marc-André Bombeck 28a8737124 feat(anamnesis): life-circumstances card with AUDIT-C, screening hints and free-text take-over 2026-10-16 01:09:50 +02:00
Marc-André Bombeck 17d1c00aab docs(api): list the pulse day-mean maintenance error codes 2026-10-16 01:07:39 +02:00
Marc-André Bombeck 46e82efe22 test(pulse): prove the day-mean method across readers, the dry run's single write and the bounded heap 2026-10-16 01:07:24 +02:00
Marc-André Bombeck 9f17cef8e4 feat(admin): pulse day-mean dry run, apply step and admin card 2026-10-16 00:58:48 +02:00
Marc-André Bombeck db750ef246 feat(anamnesis): carry life circumstances into the coach context, the doctor report and FHIR 2026-10-16 00:58:34 +02:00
Marc-André Bombeck f2be772eaf feat(anamnesis): serve the life circumstances, record AUDIT-C, and keep both off the closed legacy lists 2026-10-16 00:54:38 +02:00
Marc-André Bombeck 877920f5f7 feat(pulse): weigh sparse hours in the pulse day mean by the stored method 2026-10-16 00:49:41 +02:00
Marc-André Bombeck 1e7fc08f57 chore: drop exports the composition and marker merge left unread 2026-10-16 00:37:02 +02:00
Marc-André Bombeck 0dd634c9c8 Merge branch 'v143/links' into release/v1.43.0
# Conflicts:
#	docs/api/openapi.yaml
#	src/__tests__/contract-stub-inventory.test.ts
#	src/app/api/medications/[id]/route.ts
#	src/components/medications/detail/medication-detail-tabs.tsx
#	src/lib/export/backup-plan.ts
2026-10-16 00:34:53 +02:00
Marc-André Bombeck 3fc37c9183 Merge branch 'v143/ios' into release/v1.43.0 2026-10-16 00:28:40 +02:00
Marc-André Bombeck 6146082824 Merge branch 'v143/supplements' into release/v1.43.0
# Conflicts:
#	docs/api/openapi.yaml
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/ko.json
#	messages/pl.json
#	src/__tests__/sharing-surface-guard.test.ts
#	src/lib/ai/capabilities/types.ts
#	src/lib/export/backup-plan.ts
2026-10-16 00:28:29 +02:00
Marc-André Bombeck 1481e38fae feat(links): show related records and file the new pairs on the web
A shared related-records component renders related[] as rows that open the
server-resolved path, with how a derived relation runs. The medication
overview lists them and, in the account's own record, files its condition,
documents and visits; the visit sheet files medications and doses. The
condition page, the document sheet and the dose sheet list what their own
blocks do not already show. A dose's visit named by the form's field counts
as the same relation as the link table.
2026-10-16 00:25:04 +02:00
Marc-André Bombeck b7706d2ecc test(mood): read the patterns through the real aggregate, note days included 2026-10-16 00:22:23 +02:00
Marc-André Bombeck c5da5debb2 test(medications): hand the label upload a plain byte array 2026-10-16 00:19:34 +02:00
Marc-André Bombeck a0f6b89e92 feat(measurements): take hourly sums of summed types into the intraday profile and a backfill trigger that moves no live stamp; fix(backup): restore qualitative lab markers 2026-10-16 00:18:35 +02:00
Marc-André Bombeck b35da281aa test(sharing): drive the composition PUT through the manage matrix 2026-10-16 00:17:11 +02:00
Marc-André Bombeck 5c039a220b Merge branch 'v143/sso' into release/v1.43.0
# Conflicts:
#	docs/api/openapi.yaml
#	src/__tests__/contract-stub-inventory.test.ts
2026-10-15 23:59:03 +02:00
Marc-André Bombeck fb3fbd5860 test(medications): pin the upper-limit day and the history recompute on rows the route keeps 2026-10-15 23:58:06 +02:00
Marc-André Bombeck b4bc039d16 feat(links): close the relation matrix and publish related[] on six details
The link service gains medication and lab result sources and the five new
tables, each readable from both ends. POST/DELETE /api/medications/{id}/links
and /api/labs/{id}/links replace their 501 stubs; the visit links route also
files medications and doses, the dose links route its visit. A scan commit
with a documentId files each written result against its report.

GET on a lab result, visit, document, condition, medication and dose now
carries related[]: stored links, the condition a result shares a visit with,
and the medication whose efficacy target is the result's marker, each with
its web path. A kind is listed only when its module is on and the caller's
grant covers its section. relation-matrix.test.ts holds every one of the
fifteen pairs to a table, a reader or a written exception.
2026-10-15 23:57:34 +02:00
Marc-André Bombeck ffd46b017b feat(backup): carry the five relation link tables both ways
Lab result to document, visit to medication, visit to dose, medication to
document and medication to condition now ride the full backup in their own
section file and come back with their original filing instant. A link whose
end the restore did not write is dropped and reported under the new
relationLink skip kind. The tables leave the coverage register for the
two-ended list, guarded by relation-links-backup-completeness.test.ts and
seeded in the round trip.
2026-10-15 23:57:27 +02:00
Marc-André Bombeck 6e7db8d72c fix(oidc): send a claim's state refusal through its own browser-only redirect 2026-10-15 23:55:43 +02:00
Marc-André Bombeck 8ac19e422a docs(api): regenerate the contract for the backup's medication composition 2026-10-15 23:54:09 +02:00
Marc-André Bombeck fe687b0870 test(nutrients): pin that the batch still refuses the supplement-only codes 2026-10-15 23:52:50 +02:00
Marc-André Bombeck a2db048a4d test(medications): cover the composition, the supplement recompute, the label read and the routes end to end 2026-10-15 23:52:30 +02:00
Marc-André Bombeck 01fef691fa feat(web): edit a supplement's composition, read it from a label photo, and show nutrient sources with the upper limit 2026-10-15 23:48:43 +02:00
Marc-André Bombeck 937678e5ea test(logging): keep the handover token out of the single-sign-on claim's logs 2026-10-15 23:39:40 +02:00
Marc-André Bombeck 8c406c3f8b docs(sharing): document handing a record over, including through single sign-on 2026-10-15 23:39:39 +02:00
Marc-André Bombeck 79f7eb199b feat(insights): resolve direction verdicts, chart reference lines and mood patterns on the server 2026-10-15 23:39:11 +02:00
Marc-André Bombeck 9fd0cf264d test(claim): the handover works on a single-sign-on-only server, the password claim does not 2026-10-15 23:38:39 +02:00
Marc-André Bombeck 9d32a8acb0 feat(medications): let a delegate manage a composition, bypass the proxy for label uploads, and keep the guards current 2026-10-15 23:37:26 +02:00
Marc-André Bombeck 0ebb61343e feat(claim): offer the identity provider on the claim page and name the way in to guardians
The claim page shows the password form, a continue-with-provider button, or
both, as the preview names them, and explains a refused single-sign-on claim
when the callback sends the browser back. The guardian's handover panel says
how the person will sign in.
2026-10-15 23:34:38 +02:00
Marc-André Bombeck eb3cc4237f feat(medications): store a supplement's composition, read its label with the document AI, and carry it through backup and restore 2026-10-15 23:29:40 +02:00
Marc-André Bombeck e292db1fbd feat(notifications): give guardian and discreet deliveries the app root as tap target
Every visible delivery now carries a url. A managed-record delivery to a
guardian and a discreet one open "/", which names no event and no record;
only the silent intake sync push stays without one. The tap-target test walks
every event type across own, guardian and discreet delivery and the channels
that carry the absolute url. Document the forward-auth exemption for the
quick-answer route.
2026-10-15 23:25:51 +02:00
Marc-André Bombeck 98d64c2052 feat(nutrients): add DHA, EPA, input units and upper limits to the catalogue, and count supplement doses as a nutrient source 2026-10-15 23:19:45 +02:00
Marc-André Bombeck b91328d67e feat(records): publish an age- and sex-aware benchmark beside each personal record 2026-10-15 23:18:58 +02:00
Marc-André Bombeck 3fd6bd6081 test(claim): drive the single-sign-on claim against a mock identity provider
Check the link before the code is redeemed and refuse a live session first,
so a refused claim spends nothing at the provider.
2026-10-15 23:18:24 +02:00
Marc-André Bombeck 326619069b fix: route the AI usage and lab merge error toasts through the error-id helper 2026-10-15 23:16:52 +02:00
Marc-André Bombeck b562587c45 feat(cycle): name the start a period start folds in and the starts a removal gives back 2026-10-15 23:16:11 +02:00
Marc-André Bombeck 9eae352bb1 feat(claim): hand over a managed profile through single sign-on
POST /api/auth/claim/sso checks the handover link and the username and seals
the link id, the username and a per-start binding into an hl_claim cookie.
GET /api/auth/oidc/login?intent=claim carries the binding into the encrypted
OIDC state, and the callback binds the provider subject to the profile in the
claim transaction, refusing a subject or verified email another account
holds, a link that is no longer usable, and a live session.

The handover status and the claim preview name the redemption methods; a
single-sign-on-only server hands over through the identity provider, and only
the password claim stays refused there.
2026-10-15 23:12:59 +02:00
Marc-André Bombeck fd44a8381b Merge branch 'v143/ai' into release/v1.43.0
# Conflicts:
#	docs/api/openapi.yaml
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/ko.json
#	messages/pl.json
#	src/__tests__/contract-stub-inventory.test.ts
2026-10-15 23:12:51 +02:00
Marc-André Bombeck 74fa4e10b6 Merge branch 'v143/labs' into release/v1.43.0 2026-10-15 23:06:51 +02:00
Marc-André Bombeck d45a0d5d8e docs(ai): describe the AI usage levels and where the sync gap is set
Refs #1217
2026-10-15 23:06:08 +02:00
Marc-André Bombeck ace847784f test(integration): pin the AI usage levels end to end
Against real Postgres: ai.usage on /api/auth/me with the measured use and
the estimate, the PUT and its ceiling refusal, an operator lowering the
ceiling over a stored choice, supplementLabel closing on frugal, and the
status invalidator holding a frugal account's sync to one regeneration per
scope a day while a hand entry after six hours goes through. The capability
wiring test now covers supplementLabel.

Refs #1217
2026-10-15 23:05:32 +02:00
Marc-André Bombeck e112e05a58 fix(labs): name a merge in the owner's activity feed and toast it through the outcome module
A merge by a record manager now reads as its own sentence in the owner's
activity feed, the merged toast goes through the written-outcome toast,
and the export test doubles carry the alias and merge tables.
2026-10-15 23:03:45 +02:00
Marc-André Bombeck 5dab12472f Merge branch 'v143/support' into release/v1.43.0 2026-10-15 23:01:52 +02:00
Marc-André Bombeck ec67663def feat(settings): choose the AI usage level, and set its bounds as an operator
Settings, AI gains one card with the three levels, each with what it does
and the server's estimate of tokens per day, labelled as one. Levels above
the operator's ceiling are shown disabled with the reason; a capped choice
is named and kept; a stored choice can return to the server default. The
automatic document read says when the frugal level rests it. Admin, Coach
gains the ceiling, the default and the sync gap, and the provider readout
the accounts per level. Strings in all seven languages.

The admin card's save confirms with a success toast like its neighbour
assistant-section; it is a settings write without counts, so it is pinned
in the success-affordance guard at one.

Refs #1217
2026-10-15 22:55:40 +02:00
Marc-André Bombeck 74dcc67ef7 feat(insights): let the usage level decide the background AI work
The level now sets, per record: the status-card regeneration gap after a
sync (frugal 24 h, standard the operator's gap, full 1 h) and after a hand
entry (frugal 6 h, otherwise 1 h); the status snapshot cap (10 000 compact
characters on frugal, 14 000 above); background reasoning for the briefing
and the period narratives (off on frugal); the daily briefing's form (a
short form on frugal that keeps every field and at most one
recommendation, the full contract on standard and full); and whether the
automatic document read may act (frugal reads on request only, the stored
switch is kept).

A deterministic simulation of a watch-synced day through the invalidator
counts 122 ingest-driven status generations on full (the v1.42 behaviour),
25 on standard and 2 on frugal, plus the nightly warm on each.

Refs #1217
2026-10-15 22:55:40 +02:00
Marc-André Bombeck e65ada2961 feat(labs): merge markers, edit their aliases and pick a marker on review
The marker list offers a merge with the server's preview: what moves, what
is converted, which names become aliases, and a refusal when a unit cannot
be converted; it says a merge cannot be undone. The marker page shows and
edits the other spellings and follows a merged marker to its target. Scan
and document review name the matched marker or offer its suggestions and a
new marker; the choice is remembered. The biomarkerMerge feature flag is
on.
2026-10-15 22:55:08 +02:00
Marc-André Bombeck 9c01e179ca test(integration): compare the oidc refusals without their request ids 2026-10-15 22:55:03 +02:00
Marc-André Bombeck 24c74bd3eb test(integration): keep the requestId out of the mcp refusal it does not carry 2026-10-15 22:54:17 +02:00
Marc-André Bombeck d3770b9010 feat(admin): set the AI usage bounds and count accounts per level
PUT /api/admin/settings/assistant-flags takes an optional aiUsage block:
the highest level an account may choose, the default for accounts without
a choice (422 ai.usage.defaultAboveCeiling when above the ceiling) and the
sync regeneration gap at standard (1..48 hours, null follows
AI_SYNC_REGEN_GAP_HOURS, else 6). Lowering the ceiling caps every account
above it at once and keeps their stored choice. The provider-health readout
counts accounts per effective level.

Refs #1217
2026-10-15 22:49:01 +02:00
Marc-André Bombeck 6f673a7b84 feat(ai): resolve an AI usage level per record and publish it as ai.usage
Three levels (frugal, standard, full) with an operator ceiling and default
and a per-account choice within them, resolved in the capability layer for
the record and published on GET /api/auth/me as ai.usage: the level in
effect, the stored choice, the bounds, why (user, default, admin_cap), the
three options with whether each is allowed and an estimate of tokens per
day scaled from the record's last seven full days of coach_usage, and the
measured mean. A delegate sees the level, never the owner's spend.

PUT /api/auth/me/ai-usage stores the account's own choice or null for the
default, refusing a level above the ceiling with 422 ai.usage.aboveCeiling
and meta.maxLevel. The new supplementLabel capability needs standard or
above; below it the capability is unavailable with the new usage_level
reason, refused with 403 ai.usage.levelTooLow and meta.requiredLevel.

Refs #1217
2026-10-15 22:49:01 +02:00
Marc-André Bombeck e573f28ae8 perf(coach): keep the system prompt stable across turns and rounds
The opener hint, the first-name clause, resolved follow-ups, the table rules
and the final-round line used to be written into the system prompt, so the
cached prefix (tools plus system) was rewritten on every turn and again on
the forced final round. They travel on the user side now: the per-turn lines
in the user message, the table rules as a note after the round that produced
the first table, the final-round reason as the last user line. The system
prompt depends on the locale, the Coach preferences and the about-me text
only.

Refs #1217
2026-10-15 22:49:00 +02:00
Marc-André Bombeck c2245d1d79 perf(insights): send snapshots as compact JSON and size the cap against it
The status cards, the briefing, the derived assessments and the Coach
snapshot drop the two-space indentation from the JSON they hand the model.
The content is unchanged (the hash gate hashes the object, so stored hashes
stay valid). MAX_SNAPSHOT_CHARS is now measured on the compact string: 14 000
for the status cards and the Coach, which every snapshot that fitted the old
24 000 pretty-printed characters still fits, so the model reads the same data
for fewer tokens.

Refs #1217
2026-10-15 22:49:00 +02:00
Marc-André Bombeck ae8b614dd0 feat(insights): choose the status regeneration gap by ingest source
A sync from a watch or an integration now waits the instance's sync gap
(AppSettings.aiSyncRegenGapHours, else AI_SYNC_REGEN_GAP_HOURS, else 6 h)
before it may regenerate a status card again; a hand entry keeps the hour.
Every caller of afterMeasurementMutation and invalidateStatusInsightsForTypes
names its source, so a new ingest path cannot skip the choice.

Refs #1217
2026-10-15 22:49:00 +02:00
Marc-André Bombeck 0ec7b80a9e style(api): format the request id envelope test 2026-10-15 22:42:05 +02:00
Marc-André Bombeck 703689069f docs(support): document error ids, the support info and the support report, and ask for them in bug reports 2026-10-15 22:37:18 +02:00
Marc-André Bombeck 6979928d73 chore(web): drop toast imports the error toast helper made unused 2026-10-15 22:36:34 +02:00
Marc-André Bombeck f7386742b4 feat(web): show the error id on server failures and add the support info and report surfaces 2026-10-15 22:31:51 +02:00
Marc-André Bombeck e5544913f6 test(labs): prove merges, aliases and their backup against real SQL
The merge moves every reading and every reader shows one series, a dry run
answers the same and writes nothing, conversions keep the printed value,
an unconvertible source refuses the whole merge, a chosen spelling resolves
on the next scan and on manual entry, and aliases and the merge ledger
survive both kinds of backup. The merge route joins the record-scoped
manage inventory with its driver.
2026-10-15 22:28:32 +02:00
Marc-André Bombeck 81be73fca8 feat(labs): merge markers, keep aliases and read lab series per marker
POST /api/biomarkers/{id}/merge folds markers into one in a transaction,
converting readings where the registry knows the units and refusing the
whole merge where it does not; a dry run answers the same result and
writes nothing. Aliases appear on the marker and are edited through the
marker's PUT. Scan and document review rows name the matched marker or
offer up to three, and a chosen marker is remembered as an alias. The
doctor report, FHIR and MCP group readings by marker. Aliases and the
merge ledger travel through backup and restore.
2026-10-15 22:22:40 +02:00
Marc-André Bombeck a3ce6af9a3 feat(labs): resolve marker names through aliases and catalog shorthand
A printed name now resolves to one of the account's markers by its own
name, then by an alias of the account, then by a catalog name for the same
analyte when the reading's unit fits. The catalog gains seeds for alkaline
phosphatase and total bilirubin and a checked list of common lab shorthand
in seven languages; names that mean two analytes stay out.
2026-10-15 22:22:29 +02:00
Marc-André Bombeck 800bebd45c chore(support): drop an unused lint directive 2026-10-15 22:18:29 +02:00
Marc-André Bombeck b873ce8bc8 feat(admin): fill the redacted support report with its redaction guard 2026-10-15 22:15:50 +02:00
Marc-André Bombeck 20a1ac4f7d test(links): prove a period search returns exactly that period
Parser cases for every accepted form in de, en, es, fr, it, pl and ko and
the near misses that stay text; builder cases for every family; and an
integration walk over three years of lab results, conditions, visits and
documents, with records either side of each boundary in the person's
zone, across every page.
2026-10-15 22:11:50 +02:00
Marc-André Bombeck e16c9fb717 feat(links): search a year or a month in the link pickers by its date window
A picker term that names a period ("2019", "03.2019", "2019-03",
"März 2019", "2019년 3월") is sent as the list route's from/to window
instead of q, newest first, on every family including the visit list,
which has no text search. Month names come from Intl for the UI locale,
folded for case, accents and umlaut spellings. Instant routes get the
period's bounds in the person's timezone; the document route gets the
period's calendar days. The sheet names the period it shows.
2026-10-15 22:11:49 +02:00
Marc-André Bombeck 1af0de5e87 feat(logging): keep warnings and errors in their own ring and find them by error id 2026-10-15 22:06:39 +02:00
Marc-André Bombeck 2fd62cc115 feat(api): carry the request id in meta.requestId on every error envelope 2026-10-15 22:05:06 +02:00
Marc-André Bombeck f75a2fe343 Merge branch 'v143/w0' into release/v1.43.0 2026-10-15 21:54:07 +02:00
Marc-André Bombeck 3786deb503 feat(api): publish the v1.43 contract with routes that answer 501 until filled
Every new endpoint of the contract exists, wrapped in apiHandler, with its
authentication, module gate and request validation, and answers
501 not_implemented until its behaviour lands. The OpenAPI document carries
the new operations and schemas and the additive response fields on the
changed routes. The four server feature flags are published as false.

contract-stub-inventory.test.ts registers the stubbed routes; each one
leaves the register with the change that fills it. The route, sharing,
session, security, module-gate and outcome inventories name the new routes
deliberately, and the error-code catalogue lists the codes the routes emit
today.
2026-10-15 21:49:02 +02:00
Marc-André Bombeck 6e72de1c35 feat(db): add the v1.43 schema and drop the readable location columns
Migrations 0402 to 0410: AI usage levels, biomarker aliases and merges,
medication ingredients (with the supplement label run kind), life
circumstance fact kinds, AI sections and AUDIT-C, Dawarich context, the
pulse day-mean method, intraday hour buckets, the readable location drop
and five relation link tables. Every new column carries its reader or a
pending-consumer marker.

0409 refuses to run while any home, location period or stored day still
holds a readable location, and names the counts. Locations are read from
the sealed copy only; the encryption backfill no longer handles them, and
the demo seed writes sealed locations only.

The new tables are backed up and wiped, the new sealed columns are in the
rotation registry with labels of their own, the AI usage level and the
life-circumstance confirmations travel with the backup, and the wellbeing
score reads the mental-health screeners only.
2026-10-15 21:48:57 +02:00
Marc-André Bombeck 0b64b80343 test(sharing): count the admitted biomarker delete in the fixture inventory 2026-10-15 21:31:50 +02:00
Marc-André Bombeck 97871f7b90 fix(http): mint the fresh request id through randomId
The proxy no longer calls crypto.randomUUID itself, so it leaves the
client-random-id exemption list; the request-id helper uses the shared
randomId, which also works without a secure context.
2026-10-15 21:31:50 +02:00
dependabot[bot] 037f44d473 chore(deps): bump actions/setup-node from 7.0.0 to 7.1.0 (#1209)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 7.0.0 to 7.1.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/820762786026740c76f36085b0efc47a31fe5020...949feb2413d6458794dcd2491c4babbbce0c15c1)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-15 18:57:46 +00:00
Marc-André Bombeck 9ee89f2455 fix(http): adopt an incoming x-request-id only when it is a plain token
The header is echoed on the response and written into every log line. Both
readers (the proxy and apiHandler) now accept it only when it matches
^[A-Za-z0-9-]{8,64}$ and otherwise use a fresh id.
2026-10-15 20:24:01 +02:00
Marc-André Bombeck f72ec02484 fix(labs): charge the shared record-write bucket on OCR commit
POST /api/labs/ocr/commit can write a whole report and mint markers, yet it
was the one lab write without a ceiling. It now charges the same per-account
record-write bucket as POST /api/labs and POST /api/biomarkers and answers
429 record_write.rate_limited once exhausted.
2026-10-15 20:24:01 +02:00
Marc-André Bombeck 5d6216a59f fix(labs): resolve biomarker delete through the record-scoped labs authority
DELETE /api/biomarkers/{id} used a bare requireAuth() while the PUT beside
it requires requireRecordAuth("manage", "labs"). The delete now uses the
same authority and, as a manage-level hard delete, files what it destroyed
(name, unit, reading count) through destroyedDetails.
2026-10-15 20:24:00 +02:00
Marc-André Bombeck b4dc0d5927 Merge branch 'chore/demo-seed-v1424' into release/v1.43.0 2026-10-15 20:18:02 +02:00
Marc-André Bombeck c57abfbd68 Merge pull request #1226 from MBombeck/docs/update-dump-advice
docs(self-hosting): take the dump before pulling
2026-10-15 20:15:46 +02:00
Marc-André Bombeck 0d6bbb942d docs(self-hosting): take the dump before pulling, not after the pending-migrations line 2026-10-15 19:08:17 +02:00
Marc-André Bombeck 89ccb71a12 chore(demo): show one row of six tiles with the weight chart below it 2026-10-15 18:49:51 +02:00
Marc-André Bombeck 9839bde4f6 chore(demo): let the dental check-up fall due on the seed day 2026-10-15 18:46:19 +02:00
Marc-André Bombeck 1fe995245b Merge pull request #1225 from MBombeck/release/v1.42.4
Release v1.42.4
2026-10-15 18:14:30 +02:00
Marc-André Bombeck 140fe7277a test(e2e): wait for the lab overview read and drop an unused picker type 2026-10-15 17:31:31 +02:00
Marc-André Bombeck b1c59b908c chore(release): v1.42.4 2026-10-15 16:43:39 +02:00
Marc-André Bombeck d0b9cd8b5d Merge branch 'v1424/review' into release/v1.42.4 2026-10-15 16:42:53 +02:00
Marc-André Bombeck 346825149d fix(integrations): send no recovery for an incident the first run recorded silently
The watchdog's first pass after a deploy records standing incidents as
announced without paging. Their end then sent a recovery note for
something the operator was never told about. Such incidents now carry
the epoch as their announcement time, which no real announcement can
have, and close without a note.
2026-10-15 16:42:41 +02:00
Marc-André Bombeck 6dd71f0103 fix(medications): keep scoped credentials out of the dose arrival record
The dose arrival record describes the phone's Apple Health sync. The
measurement batch already leaves it alone for a narrow, single-purpose
credential; the intake batch now does the same, so the record stays
honest should the route ever accept such a credential.
2026-10-15 16:38:12 +02:00
Marc-André Bombeck f665a42789 fix(medications): converge a same-day Apple dose onto the slot's take
The extra-dose rule split a dose that HealthLog already recorded: a tap
at 08:00 and the same dose arriving from Health at 08:20 became two
doses and consumed stock twice, and a dose edited in Health (a new
record, a new time) became a second dose next to the first. The rule
now follows the calendar and the record: a take on another local day
is another dose; on the same day, a take logged in HealthLog converges
with the incoming record, a new record that names the slot as its
scheduled time is the slot's dose recorded again, and only an
unscheduled record more than a minute from the held one is an extra
dose.
2026-10-15 16:38:12 +02:00
Marc-André Bombeck 1e07ecaaaa fix(google-health): keep the hourly sync inside its job's expiry
An account whose backfill slice held it made the hourly job wait up to
eight minutes, and the sync after the wait ran as long as it needed:
past the fifteen-minute expiry pg-boss failed the job and retried it
beside the run still going. The pass now spends the job's budget: the
lock wait is capped at what is left, the sync's walks stop when the
budget is spent and leave the rest of the window to the next hour (the
cycle is not stamped), and an account reached after that is skipped.
A stop on the budget is not counted as a failure to retry.
2026-10-15 16:38:12 +02:00
Marc-André Bombeck 66be8c7c2c feat(integrations): one watchdog message for a provider-wide incident
The watchdog sent one page per account, so a provider outage or an
expired app credential paged once for every connected account. A pass
now collects its messages and sends them per provider at its end: more
than three accounts of one provider opening (or closing) an incident in
the same pass become one message naming the count, the reasons with
their earliest start and the accounts, and one recovery note. Every
incident in it counts as announced.
2026-10-15 16:38:12 +02:00
Marc-André Bombeck 0e5afe76a4 fix(integrations): skip the ledger page when the watchdog announced it
A failure streak reaching the alert threshold paged the operator even
when the watchdog had already announced an open incident for the same
connection. The ledger now checks for that announced incident first,
stamps the streak as announced and sends nothing; the watchdog's
recovery note still ends it.
2026-10-15 16:38:12 +02:00
Marc-André Bombeck 3e95755c3b fix(integrations): page only for trouble an operator can act on
The watchdog opened an incident for any degraded state that lasted a
day, so an Apple Health account whose phone app stopped running read as
no_new_data for good and paged. Incidents now open for action_required,
for provider errors and for the watchdog's own unexpected silence; stale
or stalled data alone never pages. The first pass after a deploy, with
no watch row yet, records what stands as already announced instead of
paging every long-broken connection at once.
2026-10-15 16:38:12 +02:00
Marc-André Bombeck 1c05813b82 fix(google-health): keep the skip's place across backfill slices
A walk that reads without the edge bound passed over everything earlier
slices wrote on every slice, and once it crossed the edge the skipped
pages were counted against the walk's page ceiling. The skip now stays
counted for the whole walk, runs on the slice's clock and its own
three-minute limit, and saves the page token it stopped at (on a limit,
a rate limit or the slice budget) so the next slice re-reads that one
page and goes on. A saved place counts as progress only when it moves
forward; a token Google refuses, or one that no longer reaches the edge,
sends the walk back to the newest page as before.
2026-10-15 16:38:12 +02:00
Marc-André Bombeck 73b9870338 Merge branch 'v1424/w2' into release/v1.42.4 2026-10-15 15:42:46 +02:00
Marc-André Bombeck 17478b08aa test(healthkit): type the card fixture's arrival fields 2026-10-15 15:38:42 +02:00
Marc-André Bombeck 09072c9f62 test(healthkit): pin dose arrivals, the background date and migration 0401 over enum rows 2026-10-15 15:36:20 +02:00
Marc-André Bombeck 03b24461ad feat(healthkit): record Apple Health dose arrivals and the last background arrival per type
Migration 0401 turns healthkit_type_syncs.type into text so the ledger can
hold MEDICATION_DOSE, and adds last_background_at (seeded from rows whose
latest arrival was a background one). POST /api/medications/intake/bulk
takes syncTrigger and records APPLE_HEALTH dose arrivals; the Apple Health
status read serves a MEDICATION_DOSE freshness entry and lastBackgroundAt
on every entry with arrival facts.
2026-10-15 15:33:30 +02:00
Marc-André Bombeck 6313c0174a Merge branch 'v1424/readiness' into release/v1.42.4 2026-10-15 15:24:29 +02:00
Marc-André Bombeck f95c2bfd41 Merge branch 'v1424/cards' into release/v1.42.4 2026-10-15 15:22:12 +02:00
Marc-André Bombeck eedd91ec64 test(e2e): drive the integration cards through every health state
One mocked page carries a provider in each state, checked at desktop width
and at 390 px by data attributes, with the menu driven from the keyboard,
the copied diagnostics compared with the shown text, and an axe pass in both
themes. The solid destructive badge replaces the red-on-red wash, which
fell below AA.
2026-10-15 15:21:49 +02:00
Marc-André Bombeck 91ccd154b1 test(labs): register the overview route in the sharing and module-gate inventories 2026-10-15 15:19:46 +02:00
Marc-André Bombeck eab4d644f7 feat(ops): log pending migrations before migrate deploy and document a dump before every update 2026-10-15 15:12:57 +02:00
Marc-André Bombeck e1369996ee Merge branch 'v1424/oura-followups' into release/v1.42.4 2026-10-15 15:10:59 +02:00
Marc-André Bombeck 648bb0d985 fix(medications): walk intakes to the drug-level horizon, read every symptom in a window, and guard fixed client fetch limits 2026-10-15 15:10:53 +02:00
Marc-André Bombeck 73922c039e fix(insights): say what fills the recovery page when it is empty
The empty note told a connected Oura account to connect Oura. The page
charts the WHOOP and Polar recharge, strain and load readings and the
Oura resilience level; Oura's readiness is the recovery score linked
above. The note now says so.
2026-10-15 15:10:18 +02:00
Marc-André Bombeck 35a33ca173 fix(oura): skip rejected and deleted sleep periods
Oura returns detected periods the person dismissed (type rest) and ones
they deleted in the sleep collection. They were imported as sleep, with
their lowest heart rate, HRV and breathing rate, which added an extra
session to the day. They now map to nothing, and rows an earlier sync
stored for such a period are soft-deleted on the next sync through the
shared write tail.
2026-10-15 15:10:18 +02:00
Marc-André Bombeck d7588fe9bf fix(sleep-score): place each night's midpoint on its main session
A nap on the same wake day moved the night's latest instant to the end
of the nap and added its minutes, so the midpoint of a regular night
landed around noon. A few such days scored consistency at 0, and a nap
after the latest night scored timing at 0 as well. The night now carries
its main session (wake instant and asleep minutes), and the midpoint is
read from that alone. Sleep rhythm and chronotype read the same midpoint.
2026-10-15 15:10:18 +02:00
Marc-André Bombeck 1a5b09a73c feat(settings): one integration card for every connection
Every connection in Settings renders the same card from the server's health
field: a badge with one word, one sentence per reason, the newest data as a
meta line, a collapsed details block whose text is exactly what the copy
button copies, and an action row with the rare actions in a menu and at most
one primary button. The per-provider banners, the raw provider error line,
the token expiry, the failure counter and the button rows are gone; every
capability they carried is reachable from the sentence, the one button, the
menu or the details. The managed-profile view renders the same card,
read-only. A structural guard keeps the cards off the retired fields.
2026-10-15 15:06:03 +02:00
Marc-André Bombeck b17a250894 feat(record-settings): publish connection health for a managed record's integrations
The guardian's read of a managed record's connections now carries the same
server-resolved health the owner's own envelope does, loaded for the record
and never for the guardian. The legacy state stays on the wire for one
transition release.
2026-10-15 15:06:02 +02:00
Marc-André Bombeck 7c905249fb fix(medications): page the side-effect list and name every GLP-1 medication in the timeline 2026-10-15 15:05:51 +02:00
Marc-André Bombeck 53e60f43f1 feat(labs): list every marker from a server-side per-biomarker overview 2026-10-15 15:03:06 +02:00
Marc-André Bombeck 600145427b docs(measurements): describe the 10-minute heart-rate bucket in the batch route header 2026-10-15 14:58:15 +02:00
Marc-André Bombeck 8ebc6fe546 fix(integrations): give Oura and WHOOP a 48 h silence window
Their rings and straps upload only when the phone app is opened, so a day
without opening it is ordinary and must not page the operator. Wearables
whose cloud receives data on its own keep 24 h, Apple Health keeps 48 h,
and scales, cuffs and Strava never alert on silence.
2026-10-15 14:30:55 +02:00
Marc-André Bombeck 228106be5f Merge branch 'chore/demo-seed-v1423' into release/v1.42.4 2026-10-15 14:26:11 +02:00
Marc-André Bombeck a4942e631f Merge branch 'v1424/ghfallback' into release/v1.42.4 2026-10-15 12:45:23 +02:00
Marc-André Bombeck 1f9180fe6f test(google-health): drive a backfill to completion against a Google that refuses the edge filter
Also documents the fallback and its log fields.
2026-10-15 12:35:49 +02:00
Marc-André Bombeck ebc0168f53 test(google-health): pin the fallback when Google refuses the resume edge filter 2026-10-15 12:33:34 +02:00
Marc-André Bombeck e2d5bf121c fix(google-health): read past the resume edge when Google refuses its filter 2026-10-15 12:31:39 +02:00
Marc-André Bombeck de40db7ad0 Merge branch 'v1424/fixes' into release/v1.42.4 2026-10-15 12:23:55 +02:00
Marc-André Bombeck 7be05608f8 test: date fixtures that feed a clock-relative window relative to now
A fixture dated near the day it was written passes until the date slides
out of a window the route counts back from the clock. Five did: a snooze
until a fixed date that becomes the past in January, a prognosis read
against the wall clock over days seeded at a fixed date, sleep rows and a
weight streak read through windows that end now, and a sync token that
would expire on a fixed date. The e2e mood heatmap mock and a timeline
URL are dated from today as well.

A guard scans the integration and e2e trees for near-now absolute dates
within reach of a window parameter, with an allowlist for files whose
windows are fixed on both ends.
2026-10-15 12:23:09 +02:00
Marc-André Bombeck b96c114a45 Merge branch 'v1424/gh' into release/v1.42.4 2026-10-15 11:45:45 +02:00
Marc-André Bombeck c8194c4a86 test(google-health): drive a three-year heart-rate backfill through the drain to completion
1.58 million readings, paged by a generated fake of the list API that
honours the edge and lower-bound filters, with a server error, a timeout and
an hour-long rate limit injected. Pins that the backfill completes over
several slices, imports every reading exactly once without rewriting a row,
lets the hourly sync run between and beside slices, writes one log event per
slice with its requests and edges, and keeps the heap flat across the
history under the production heap ceiling.
2026-10-15 11:45:15 +02:00
Marc-André Bombeck 2d03eba587 fix(google-health): resume a backfill inside a collection and run it in short slices
A backfill noted progress only once a whole collection was read to the end.
A heart-rate history of years that one attempt could not finish (its budget,
a rate limit it could not wait out, a single failed request among thousands
of pages) started the next attempt from the newest page again, finished
nothing, and failed with "marker not stamped" on every attempt.

- Every spot-reading collection read page by page now records, after each
  page it writes, the oldest sample it has written (backfillProgress.cursors).
  The next attempt asks Google only for samples at or beyond that edge. The
  edge is held back once a page's write fails, so no rows are skipped.
- The backfill runs in slices of five minutes or 500 pages. A slice that
  finished a collection or moved an edge continues with the next one; only a
  slice that moved nothing fails, and its error names each unfinished
  collection and why the slice stopped. The slice ceiling is derived from the
  collections and the page ceilings of the resumable ones.
- A rate limit Google will not lift within a minute ends the slice instead of
  failing the walk, and the next slice starts five minutes later. Server
  errors, timeouts and dropped connections are retried three times.
- The heart-rate stream is read last, so the light collections finish in the
  first slice.
- The hourly sync waits up to eight minutes for a running slice instead of
  skipping the account for as long as the import runs.
- Each slice annotates googleHealth.backfill.slice with pages, elapsed time,
  stop reason, per-collection status and edges, and reports progress on the
  job.progress line.
2026-10-15 11:26:49 +02:00
Marc-André Bombeck 47deda9d76 Merge branch 'v1424/integrations' into release/v1.42.4 2026-10-15 11:26:37 +02:00
Marc-André Bombeck 73c4dda224 fix(visits): reach every lab result, condition, document and visit from the link pickers
The visit form's lab picker offered the newest 200 results, the condition
picker the newest 100 conditions, the document pickers the newest thousand
documents and the document sheet's visit picker the newest 100 visits.
On a long record everything older could not be linked.

Each picker now opens on the records around the date of the thing being
linked, pages older and newer from there, and searches through the list
route itself. Records already linked always show, from the labels the
record's own read carries and from what was picked in the sheet.

The lab list takes a q filter over analyte and panel, the condition list
from/to/q/offset/sortDir, and the visit list an offset.
2026-10-15 11:24:40 +02:00
Marc-André Bombeck f2646bc186 test(integrations): cover the health field on the status routes 2026-10-15 11:15:06 +02:00
Marc-André Bombeck 4a49784464 chore(demo): file the demo medications under categories, let the evening supplement record itself, and store two lab readings converted from SI units 2026-10-15 11:11:57 +02:00
Marc-André Bombeck 8162afc0e1 docs(api): publish the connection health state 2026-10-15 11:09:50 +02:00
Marc-André Bombeck c094f217fc feat(integrations): watch connections for silence and lasting trouble
An hourly and a daily pass flag a continuous source that delivered on
most of the previous 14 days and has gone quiet, and open one operator
incident per connection when a connection stays degraded or needs action
for more than a day. The incident is announced once through the existing
admin alert path, adopted rather than repeated when the sync ledger
already paged, and closed with one recovery note. Scales, cuffs and
workout logs are watched for errors only, never for silence.
2026-10-15 11:07:57 +02:00
Marc-André Bombeck 82d8960044 fix(jobs): run every admitted import inside its own background event
The admission drain ran each full-history import with no wide event in
scope, so every external call, warning and annotation the provider walk
recorded was dropped and a failing backfill left only its error message.
Each admitted run now opens a job.integration_backfill event with its kind,
identity, job id and slice, emitted when the run settles. The Health Connect
import worker gets the same treatment.

The external call list on one event is bounded so a long walk cannot hold
thousands of entries: failed and annotated calls keep the larger share,
the rest is counted in external_calls_omitted.
2026-10-15 11:07:28 +02:00
Marc-André Bombeck dad9a7778e fix(medications): keep an extra Apple Health dose off a slot that already holds one
A HealthKit dose event binds to a slot by window band, and a weekly
schedule's band reaches a day either side of its slot. An extra dose
logged the day after a scheduled one landed on that slot and overwrote it
last-write-wins: the slot's time changed and the extra dose was stored
nowhere. An external dose record whose band slot already carries a
different take is now recorded as its own unscheduled dose.

The dose history now lets the take stored on a slot claim it before an
extra dose stored on its own instant, instead of following the order the
rows were read in, so the extra dose reads as off-schedule.
2026-10-15 11:02:38 +02:00
Marc-André Bombeck d10179f9a3 feat(integrations): resolve one health state per connection
Fold the ledger state, liveness verdict, plan and scope gaps and the
provider reauth flag into ok / limited / degraded / action_required with a
reason code, the one action, the unavailable types and redacted
diagnostics. Keep the last ten sync attempts per connection for those
diagnostics, publish the state on the integrations envelope, every
per-provider status route and the Apple Health config read, and page the
operator once per failure streak instead of once a day.
2026-10-15 11:01:16 +02:00
Marc-André Bombeck cdd1e9c87f Merge pull request #1219 from MBombeck/fix/lab-dialog-overflow
fix(labs): keep the lab form's single column at the dialog width
2026-10-15 07:06:49 +02:00
Marc-André Bombeck 07d991adf7 fix(labs): keep the value and draw-date column inside the dialog body
The single-column grid used the implicit auto track, which grows to the
widest cell's min-content. The draw date's date and time inputs size
from the font, so with wider metrics that min-content passed the dialog
body (about 422 px against 398 px) and the value input ran 24 px into
the dialog's padding. An explicit grid-cols-1 track is minmax(0, 1fr)
and stays at the body's width; the date row already shrinks inside it.

Refs #1214
2026-10-14 21:01:13 +02:00
Marc-André Bombeck 056af17f62 Merge pull request #1218 from MBombeck/release/v1.42.3
Release v1.42.3
2026-10-14 20:33:50 +02:00
Marc-André Bombeck e0573069c5 test(sharing): seed the series weight relative to now so it stays in the 90-day window 2026-10-14 20:09:14 +02:00
Marc-André Bombeck 38ef4c7fa7 fix(labs): keep three significant digits for a lab value below one 2026-10-14 19:57:34 +02:00
Marc-André Bombeck 784ea4ca9d Merge branch 'v1423/review' into release/v1.42.3 2026-10-14 19:57:04 +02:00
Marc-André Bombeck fcced06fa7 fix(labs): never open a small lab value as zero in the edit sheet
The edit seed rounded every value to two decimals, so a TSH of 0.004 opened
as 0. Values at or above 1 keep the two-decimal display precision; below 1
the seed keeps three significant figures, written without exponent
notation.
2026-10-14 19:50:26 +02:00
Marc-André Bombeck 03e80c1a94 fix(labs): drop the printed range text when an edit retires the conversion
Changing the value of a converted reading cleared sourceValue and
sourceUnit but kept the printed range text, which is written in the
report's unit. With the unit gone every reader presented it as the marker
unit's window, beside numbers in another unit. The text now goes with the
conversion record (a whole-form echo of it does not bring it back); the
bounds, already converted into the marker's unit, stay.
2026-10-14 19:49:33 +02:00
Marc-André Bombeck 530d1a6be2 fix(oura): keep the scope note when a run never proved the token
The refresh-abort paths (no client credentials, refresh token lost to a
peer) and a run where no collection succeeded wrote an empty scope list,
clearing the reconnect hint until the next good run. Such a run learned
nothing about the grant, so the stored note now stands.
2026-10-14 19:47:37 +02:00
Marc-André Bombeck 55ddad3d6a fix(oura): read a 401 as a scope gap only on the scope-gated collections
A 401 on any collection next to a success was recorded as a scope gap, so a
real auth failure on sleep, readiness or activity was hidden from the
failure ledger and shown as a reconnect hint. Only spo2, vo2max, cardio_age
and resilience sit behind a scope beyond daily; a 401 anywhere else keeps
its normal failure handling, and a stored note naming another collection
no longer skips the token refresh.
2026-10-14 19:47:05 +02:00
Marc-André Bombeck c7164be0d3 fix(withings): keep the plan re-probe date on a run that only narrowed
A run that skipped remembered types and met one more refusal restamped the
leg's checkedAt, pushing the weekly re-probe of the remembered types out on
every such run. Only a re-probe, or a leg with nothing remembered yet, now
starts the week again.
2026-10-14 19:46:03 +02:00
Marc-André Bombeck 6feaf6849a fix(withings): keep weight and blood pressure when every request is refused
A run where Withings refused every requested type stored all of them, core
types included, as outside the plan for a week; later runs then requested
nothing and moved the watermark on with no readings, losing the gap. A
refusal is now remembered only from a run in which another type was
accepted. A run that read nothing keeps the watermark and reports that it
did not land, and the watermark also holds while plan memory skips a core
type (weight, blood pressure, pulse, free body composition).
2026-10-14 19:45:38 +02:00
Marc-André Bombeck 8ce6a83da5 fix(withings): never remember a type the plan probe could not resolve
When the request budget ran out mid-split, every subset not yet tried was
stored as refused, so free types (temperature, SpO2, body composition) could
be skipped for a week and named as withheld by the plan. Those types are now
unverified: not remembered, asked for again on the next run, and the measure
watermark holds until every type was read or proven refused.
2026-10-14 19:44:05 +02:00
Marc-André Bombeck e9be8e0a5f chore(bundle): raise the aggregate cap to 4790 on a measured delta 2026-10-14 19:42:40 +02:00
Marc-André Bombeck 787aafaee1 docs(changelog): list only the analytes the registry converts 2026-10-14 19:41:19 +02:00
Marc-André Bombeck 9d167069db chore(release): v1.42.3 2026-10-14 19:35:25 +02:00
Marc-André Bombeck f564c44753 chore(labs): drop the pending-consumer markers now that the readers have landed 2026-10-14 19:35:00 +02:00
Marc-André Bombeck 42b66b2228 Merge branch 'v1423/labs' into release/v1.42.3 2026-10-14 19:34:44 +02:00
Marc-André Bombeck 035ae37ee4 Merge branch 'v1423/integrations' into release/v1.42.3 2026-10-14 19:20:16 +02:00
Marc-André Bombeck ec57ae10b1 docs(labs): describe the registry listing by what uses it
Rewords a code comment that referred to the work by its planning step, and a
loop counter's comment, to say what the code does.
2026-10-14 19:20:01 +02:00
Marc-André Bombeck eabbe3e032 fix(labs): let the conversion badge in the scan review wrap
At 390 px the badge truncated, and the part it cut off was the value the
report printed. It now wraps inside the row like a sentence.
2026-10-14 19:19:44 +02:00
Marc-André Bombeck 65669e52d2 fix(withings): bind the plan-state timestamp instead of reading the database clock 2026-10-14 19:19:27 +02:00
Marc-André Bombeck 350fd1b767 fix(labs): name calcifediol as the source of the vitamin D factor
The note said the factor was taken from cholecalciferol (C27H44O2). Vitamin
D3 is C27H44O; C27H44O2 is 25-hydroxyvitamin D3 (calcifediol), the form the
assay reports and the one whose molar mass gives 1 ng/mL = 2.496 nmol/L. The
factor was right; the note now says what it rests on.
2026-10-14 19:19:15 +02:00
Marc-André Bombeck 07f09e1b61 fix(labs): store the catalog slug as a marker's analyte key
The matcher stored the registry entry's key, so LDL and HDL markers were
recorded as total-cholesterol. The key now names what the marker is (the
catalog slug, or a registry-only key for an analyte with no seed), and the
registry resolves ldl and hdl to the cholesterol entry for the conversion as
before. A key already written in the old form is rewritten by the same fill
step that brings a stale key in line with its name, the first time a reading
needs it. The OpenAPI descriptions say what the key is and that it follows
the marker's name and unit.
2026-10-14 19:18:49 +02:00
Marc-André Bombeck cd1449e63e fix(labs): keep a reading's conversion record unless its value actually changes
The single-reading edit cleared sourceValue and sourceUnit whenever the body
carried a value, and the web edit sheet always sends one, so a date or note
edit erased the "converted from" hint. It is now cleared only when the value
differs from the stored one. The sheet also opened a converted reading at its
stored precision (100.90191999999999); it now starts from the value as the lab
surfaces show it and leaves an untouched value out of the request, so the
stored full-precision number is not replaced by its rounded display.
2026-10-14 19:15:33 +02:00
Marc-André Bombeck 47a91d6922 fix(labs): never present a converted reading's printed range in the wrong unit
A converted reading's bounds move with its value, but the printed range text
stays in the unit the report used, and every reader preferred that text: the
list showed "Report range 3.9 to 5.5" beside 100.9 mg/dL, and the doctor
report and the FHIR referenceRange.text did the same. The text is still
stored verbatim. The range resolver now takes the reading's sourceUnit (as a
required field, so no reader can forget to select it) and, for a converted
reading, leaves the window's text empty so every surface states the converted
bounds in the marker's unit, and hands the printed text over separately with
its unit. The lab DTO carries it as printedReferenceText, and the note beside
a reading shows it as "as printed in mmol/L: ...".
2026-10-14 19:10:49 +02:00
Marc-André Bombeck 4793edb961 test(integrations): pin the Withings plan handling and the Oura scope gap; document the plan change 2026-10-14 19:08:22 +02:00
Marc-André Bombeck 9c5fa6d14a Merge branch 'v1423/fixes' into release/v1.42.3 2026-10-14 19:05:56 +02:00
Marc-André Bombeck 044f9008ba fix(oura): ask for the spo2 and heart_health scopes, explain a missing scope calmly
Oura's current authorization server enforces scopes per collection.
daily_spo2 needs spo2; vO2_max, daily_cardiovascular_age and
daily_resilience need heart_health. The authorize request now asks for
both.

A grant issued before keeps answering 401 on those four collections
while every other one succeeds. Such a 401 next to a success is now
recorded as a scope gap rather than a failure: it no longer counts
toward the reconnect threshold, it no longer forces a token refresh once
known, and the Oura card asks for one reconnect instead. The Withings
card names the readings the account's API plan withholds.
2026-10-14 19:01:02 +02:00
Marc-André Bombeck 8b18b1c9fe test(e2e): fail clearly on a medications POST body that is not JSON
The route stub parsed the body inside the un-awaited route callback, so
a malformed body threw there and the request hung. The parse error is
now recorded and the route is fulfilled; reading capture.postBody in
the spec throws with the parse error.
2026-10-14 18:59:33 +02:00
Marc-André Bombeck cc0313c955 fix(labs): re-derive a marker's analyte key when its name or unit changes
The key is a function of the marker's name and unit, but it was only ever
filled, never replaced, so a marker renamed from Glucose to Cholesterol kept
converting readings with the glucose factor. The marker edit now re-derives
it with the same match the creation paths use (clearing it when the new name
matches nothing), and the fill step a reading runs brings a stored key back in
line with the name, so a key written before this fix is corrected the first
time it matters. The scan review derives the key the same way as the commit.
2026-10-14 18:59:32 +02:00
Marc-André Bombeck 63882bf3ec fix(labs): give the draw date a full-width row
The lab form placed the draw date in the second column of a two-column
grid. DateTimeField sets its date and time halves side by side from sm
up, so the date half was squeezed until a date could only be set
through the calendar. The value and the date now stack in one column,
as in the measurement form, and the date can be typed again.

Refs #1214
2026-10-14 18:58:55 +02:00
Marc-André Bombeck cc507deafa fix(notifications): log every refused notification answer
A quick answer whose request never reached the route left no trace: a
redirect in front of the app turns ntfy's POST into a GET, which Next
answered 405 before any wide event, and the worker-only and demo-mode
refusals in the proxy wrote nothing either. The proxy now answers a
non-POST on the action path itself with 405 and Allow: POST, and every
refusal it makes there writes one warn line with a reason and the path
without its token. The route's rate-limit refusal carries a reason too.

The regression test sends the action ntfy publishes, in the shape ntfy
sends it, through the real proxy and route against Postgres.

Refs #1203
2026-10-14 18:57:06 +02:00
Marc-André Bombeck 9aa60fb2ff fix(illness): keep chronic conditions out of rest mode
A chronic ongoing condition has no end date, so counting it as rest mode
kept the account in it for good: Day N counted from an onset years back,
cadence nudges never resumed and every streak gap since the onset was
bridged. Only active episodes that are not CHRONIC_ONGOING (acute,
recurring, flare) now trigger rest mode, and Day N counts from the
earliest of those. The achievements streak freeze applies the same
filter to its own episode query.

Chronic conditions stay context: a coincident vital deviation is still
framed as explained by any active episode, and the Coach block and the
briefing still name them, with restMode false.

Refs #1216
2026-10-14 18:57:01 +02:00
Marc-André Bombeck 39b0cabb43 fix(withings): keep the sync inside the account's API plan
Withings split its public API into plans on 2026-10-12. A request naming
one meastype outside the free plan is refused whole with status 277, so
every measure sync stopped importing anything, and the 200..299 reauth
range parked the connection at error_reauth.

The measure, sleep-summary and ECG legs now drop the documented advanced
types on a 277, split the rest until the refused keys are isolated
(bounded), remember the refusal per connection and re-probe weekly. 277
is classified persistent and never enters the ledger; connections parked
over it are lifted by the fallback tick, and the held watermark lets the
next run read the whole gap.
2026-10-14 18:53:58 +02:00
BernhardBuckel 747e0964ad feat(labs): show a reading's conversion in the scan review and beside the value
Slice 3 of phase 3 of #1095. The scan review no longer treats every reading in
another unit as unwritable. The server tells it which analyte the matched marker
is (markerAnalyteKey on each extracted row); a row whose unit differs but can be
converted starts selected and carries a badge saying what Save will store and
what the report stated ("Saved as 100.9 mg/dL, converted from 5.6 mmol/L"). A
row that cannot be converted keeps the existing blocked badge and error. Save
still sends what the report printed; the server converts, as in slice 2.

The review's duplicate flag also recognises a re-scan of a reading that was
stored converted, by what the report printed, the same rule the commit applies.

A converted reading carries a quiet "converted from 5.6 mmol/L" on the lab list
(row and tile) and the history list (phone and table), so a number nobody typed
can always be traced back to what the report printed.

Strings in all seven locales.

Refs #1095
2026-10-11 20:41:08 +02:00
BernhardBuckel caa7a937fd feat(labs): convert a reading into its marker's unit when the pair is known
Slice 2 of phase 3 of #1095. A reading that stated a unit other than its
marker's was refused. When the marker's analyte is known and the registry has the
pair, the three write paths now convert it instead: POST /api/labs, the OCR
commit and the document commit.

The converted value is stored at full precision in the marker's unit, with what
the report printed in the existing source_value and source_unit columns. The
report's own reference window is in the printed unit, so it is converted with the
value; otherwise the verdict would compare a converted value with an unconverted
window. A pair the registry does not have is still refused as before (the 422
now says why), as is a marker whose analyte is unknown.

The marker's key is filled the first time a reading needs it. The same scan
committed again is still a duplicate: a converted row holds the number in the
marker's unit, so the duplicate check also matches on what the report printed.
Editing the value of a converted reading clears the conversion record, because
the number is then the person's own.

The lab result DTO and the OpenAPI schemas (labs and the OCR commit) carry
converted, sourceValue and sourceUnit.

Refs #1095
2026-10-11 20:28:00 +02:00
BernhardBuckel 08c69d4a5a test(labs): give the biomarker fixtures the new analyteKey field
BiomarkerDto gained analyteKey, and two component test fixtures typed as it
were missing the field, which the typecheck job rejects.
2026-10-11 20:16:31 +02:00
BernhardBuckel 52ea3b9c2f feat(labs): link a marker to its analyte for the unit conversion
Slice 1 of phase 3 of #1095. A Biomarker row keeps only its name, so nothing
said which analyte it was. This adds the explicit alias list in the catalog the
issue settled on, including the registry-only keys (urea, blood urea nitrogen,
uric acid, total calcium, total bilirubin), and sets biomarkers.analyte_key
(from migration 0385) from it.

matchAnalyteKey equals a marker's folded name against the localised names of the
catalog seeds the registry knows and against the alias list. It matches the
whole name only, so "Calcium (ionisiert)" is not "Calcium"; a name two analytes
claim matches neither; and the marker's own unit must be one the analyte reaches,
so a marker called "Glucose" in a unit the registry cannot convert stays
unlinked.

The key is set when a marker is created (the biomarker route, the mint used by
lab writes, the legacy backfill) and, for an existing marker, by
ensureAnalyteKey the first time a reading needs it. It never overwrites a key
that is set. There is no boot job for the existing markers: the conversion that
uses the key fills it on first use, and a sweep can be added later if wanted.

The key is returned on the biomarker API and in the OpenAPI schema.

Refs #1095
2026-10-11 20:04:43 +02:00
kilian c2be166f72 Merge remote-tracking branch 'origin/main' into feat/lab-analyte-key-fill 2026-10-11 19:48:56 +02:00
dependabot[bot] bc7f3bf209 chore(deps): bump the production-weekly group across 1 directory with 2 updates (#1207)
Bumps the production-weekly group with 2 updates in the / directory: [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) and [nodemailer](https://github.com/nodemailer/nodemailer).


Updates `lucide-react` from 1.52.0 to 1.53.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.53.0/packages/lucide-react)

Updates `nodemailer` from 10.0.15 to 10.0.16
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v10.0.15...v10.0.16)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.53.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
- dependency-name: nodemailer
  dependency-version: 10.0.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production-weekly
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-11 17:17:03 +00:00
dependabot[bot] d748db0e10 chore(deps-dev): bump the development-weekly group with 3 updates (#1210)
Bumps the development-weekly group with 3 updates: [@playwright/test](https://github.com/microsoft/playwright), [lefthook](https://github.com/evilmartians/lefthook) and [shadcn](https://github.com/shadcn-ui/ui/tree/HEAD/packages/shadcn).


Updates `@playwright/test` from 1.63.0 to 1.64.0
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.63.0...v1.64.0)

Updates `lefthook` from 2.1.17 to 2.2.1
- [Release notes](https://github.com/evilmartians/lefthook/releases)
- [Changelog](https://github.com/evilmartians/lefthook/blob/master/CHANGELOG.md)
- [Commits](https://github.com/evilmartians/lefthook/compare/v2.1.17...v2.2.1)

Updates `shadcn` from 4.21.3 to 4.21.4
- [Release notes](https://github.com/shadcn-ui/ui/releases)
- [Changelog](https://github.com/shadcn-ui/ui/blob/main/packages/shadcn/CHANGELOG.md)
- [Commits](https://github.com/shadcn-ui/ui/commits/shadcn@4.21.4/packages/shadcn)

---
updated-dependencies:
- dependency-name: "@playwright/test"
  dependency-version: 1.64.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-weekly
- dependency-name: lefthook
  dependency-version: 2.2.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-weekly
- dependency-name: shadcn
  dependency-version: 4.21.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-weekly
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-11 16:48:18 +00:00
dependabot[bot] 3f08e3a97b chore(deps): bump actions/upload-artifact from 7.0.1 to 7.0.2 (#1208)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 7.0.1 to 7.0.2.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/043fb46d1a93c77aae656e7c1c64a875d1fc6a0a...cf430e030ddbb5b0abf93d22962f4752f3646cd9)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-11 16:35:07 +00:00
dependabot[bot] 0fed7f02e6 chore(deps): bump the codeql-action group with 3 updates (#1206)
Bumps the codeql-action group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.38.2 to 4.38.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...24c54180a607b1449ed407dd24f251e4e9147c8d)

Updates `github/codeql-action/analyze` from 4.38.2 to 4.38.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...24c54180a607b1449ed407dd24f251e4e9147c8d)

Updates `github/codeql-action/upload-sarif` from 4.38.2 to 4.38.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...24c54180a607b1449ed407dd24f251e4e9147c8d)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.38.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.38.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.38.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-11 16:19:49 +00:00
Marc-André Bombeck c6db4501f1 Merge pull request #1205 from MBombeck/release/v1.42.2
Release v1.42.2
2026-10-11 16:10:10 +02:00
Marc-André Bombeck b9471c3a62 docs(changelog): describe the fitted timeline window and the ended-course rule 2026-10-11 15:35:04 +02:00
Marc-André Bombeck e833bd0949 Merge branch 'v1422/review2' into release/v1.42.2 2026-10-11 15:33:51 +02:00
Marc-André Bombeck a54b47428e test(timeline): match the zoom each journey opened with
The day-strip journey opens /timeline?zoom=all, so closing the day
returns there, not to bare /timeline. An unreadable range now opens on
the default window, which for this record is three months.
2026-10-11 15:25:05 +02:00
Marc-André Bombeck ab104ab633 refactor(medications): spread the intake-tracking filter at each auto-take reader
The shared eligibility filter hid TRACKED_INTAKE_WHERE from the
structural guard that checks every schedule and intake reader honours
the switch. Each reader now spreads it itself.
2026-10-11 15:23:57 +02:00
Marc-André Bombeck 5bfdc9d04b fix(jobs): one bad medication never stops the auto-take pass
A row whose follow-up threw, or whose stored time zone the runtime
refuses, aborted the pass for every other person. Each medication now
runs on its own: a failure is named in a warning, counted, and the next
one runs; an unusable zone falls back to the default as in other jobs.
2026-10-11 15:19:08 +02:00
Marc-André Bombeck 88aec7edb6 fix(search): key the in-flight search by actor and record
Two tabs searching two different records (one's own and a shared one)
cancelled each other, because a new search aborted any earlier one by
the same actor. The slot is now held per actor and record.
2026-10-11 15:17:43 +02:00
Marc-André Bombeck 24f06884b3 fix(reminders): an ended course reads as not reminding
The worker expands no slot past endsOn, so it never reminds for a
course that has ended, but remindersActive still read true for it. The
reminder gate now includes the course end on the person's clock, so the
published value, the worker and the admin check make the same decision.
2026-10-11 15:16:54 +02:00
Marc-André Bombeck 1ba422b87c fix(backup): restart the auto-take floor at the restore
A portable file carries no removed doses, so a restored medication with
automatic recording on had the pass fill a dose the person deleted in
the days before the export. The restored floor is now the later of the
file's value and the restore.
2026-10-11 15:14:43 +02:00
Marc-André Bombeck 38900656a3 fix(audit): name automatic dose recording in medication audit rows
Switching autoMarkTaken on or off now appears in the medication.update
row with its previous value, and medication.create records whether the
medication starts with doses recorded automatically.
2026-10-11 15:11:27 +02:00
Marc-André Bombeck fe782493a1 fix(medications): a take bound to another slot never claims this one
The auto-take pass left a slot alone when any take fell inside its band,
so the morning dose pinned and taken at 19:00 also hid the 20:00 dose.
Only a take attributed to no other expected slot counts as nearby now.
2026-10-11 15:10:01 +02:00
Marc-André Bombeck dc86c42bef feat(timeline): fit the default window to the record
Without a zoom in the URL the window now follows the record: one that
starts inside the last three months opens on three months clamped to
its first entry (at least four weeks up to today), an older one with
entries there opens on three months, an older one without opens on
everything, and an empty one keeps the empty state. The choice is made
from the first answer before anything is drawn, so the page does not
jump between windows.
2026-10-11 15:08:57 +02:00
Marc-André Bombeck 0ff1340ff4 fix(medications): never backfill days an edit reopens under auto-take
A start moved earlier, an end moved later or removed, or a medication
made active again reopened past slots, and the auto-take pass filled up
to a week of them as taken. The course window writer and the
re-activation paths now move the auto-take floor to the moment of the
edit.
2026-10-11 15:07:06 +02:00
Marc-André Bombeck 23e0cc35af fix(timeline): open on everything when the default window is empty
Opened without a zoom, three months that hold nothing while the record
has older entries now show everything instead of a dead end, without
writing the zoom into the URL. A window named in the URL that is empty
offers "Show everything" when the record has entries before it. An
unreadable ?zoom= or range opens on the same default as no zoom.
2026-10-11 15:04:14 +02:00
Marc-André Bombeck a4cccc11ba fix(medications): keep an auto-taken rolling cadence moving
A rolling cadence yields only lastIntakeAt + N, so a slot the auto-take
pass never records (before the switch-on floor, past the catch-up
window, inside a pause, skipped or removed by the person) held it still
for good. Step the cadence forward by N past every such slot, keeping
its rhythm, and feed the stepped anchor to the card, dashboard and
schedule-tool next due so they show the slot the pass will record.
2026-10-11 15:00:37 +02:00
Marc-André Bombeck 0375c2adc6 test(reminders): drop the fixture fields the merge doubled 2026-10-11 14:49:43 +02:00
Marc-André Bombeck 6fe1a050a6 chore(release): v1.42.2 2026-10-11 14:42:03 +02:00
Marc-André Bombeck 765dbe1e7f Merge branch 'v1422/caption' into release/v1.42.2 2026-10-11 14:41:28 +02:00
Marc-André Bombeck 2f780df0b0 Merge branch 'v1422/autotaken' into release/v1.42.2 2026-10-11 14:41:28 +02:00
Marc-André Bombeck bfba7aec67 Merge branch 'v1422/review' into release/v1.42.2 2026-10-11 14:41:27 +02:00
Marc-André Bombeck 7ee72d0161 fix(search): admit the search POST at read level through a declared, frozen readOverPost 2026-10-11 14:21:56 +02:00
Marc-André Bombeck 4c598655b7 test(reminders): give the reminder fixtures the full gate row 2026-10-11 14:17:36 +02:00
Marc-André Bombeck 195fa28cc5 feat(medications): publish the resolved reminder decision as remindersActive 2026-10-11 14:09:22 +02:00
Marc-André Bombeck 1bf6108b37 docs(search): describe the command palette's record search 2026-10-11 14:00:31 +02:00
Marc-André Bombeck 3e2426187c test(checkups): pin that a linked visit older than the list's newest hundred is read and opened 2026-10-11 13:59:59 +02:00
Marc-André Bombeck 9a86a9427b fix(oura): name the issuer refusal on the integrations page 2026-10-11 13:59:05 +02:00
Marc-André Bombeck 5d7e0248b6 fix(search): probe one row past each cap so a source holding exactly the cap is not reported truncated 2026-10-11 13:57:43 +02:00
Marc-André Bombeck 55bfb0a439 feat(sync): carry the automatic-recording marker on intake upserts 2026-10-11 13:56:28 +02:00
Marc-André Bombeck 41049b3837 fix(search): bound the clinical kinds by the search budget, one search per actor, query in a POST body 2026-10-11 13:56:24 +02:00
Marc-André Bombeck e93b4f249f test: drop an unused constant 2026-10-11 13:55:46 +02:00
Marc-André Bombeck 2a626be92d style: format the auto-take changes 2026-10-11 13:53:40 +02:00
Marc-André Bombeck 7417d8f423 test(medications): prove the auto-take pass against Postgres 2026-10-11 13:53:35 +02:00
Marc-André Bombeck 5affa9142c test(medications): cover the auto-take pass 2026-10-11 13:52:22 +02:00
Marc-André Bombeck 4386ac820e test: register the auto-take job and field in the existing guards 2026-10-11 13:50:56 +02:00
Marc-André Bombeck 138792d555 docs(db): correct the build cost note on the noted-readings index migration 2026-10-11 13:50:30 +02:00
Marc-André Bombeck 3c6150e837 fix(admin): sum today's AI spend on each account's own local day 2026-10-11 13:50:17 +02:00
Marc-André Bombeck 67a55def44 docs(api): document the auto-mark-taken fields 2026-10-11 13:47:37 +02:00
Marc-André Bombeck c94e109bf0 feat(medications): record scheduled doses automatically 2026-10-11 13:46:16 +02:00
Marc-André Bombeck 5dbc6fcec1 fix(notifications): keep a quick answer single-use after its entry is purged and document the topic exposure 2026-10-11 13:46:12 +02:00
Marc-André Bombeck 4ff9aa7e39 fix(notifications): publish ntfy messages as JSON so non-ASCII labels need no header encoding 2026-10-11 13:43:35 +02:00
Marc-André Bombeck bc91a542e2 fix(google-health): count backfill progress only once it is persisted and cap the slice chain 2026-10-11 13:41:53 +02:00
Marc-André Bombeck 8cd6cf3c06 feat(timeline): open on three months unless the URL names a zoom 2026-10-11 13:40:19 +02:00
Marc-André Bombeck 5919a93c29 feat(medications): add the auto-mark-taken columns 2026-10-11 13:39:44 +02:00
Marc-André Bombeck b7eef0dff4 feat(charts): drop the explanatory line under day-linked charts 2026-10-11 13:39:20 +02:00
Marc-André Bombeck a4a3318de3 fix(mood): open the reminder's entry sheet only in the own record 2026-10-11 13:37:46 +02:00
Marc-André Bombeck 3c4e053dcd Merge branch 'v1422/fixes' into release/v1.42.2 2026-10-11 13:28:29 +02:00
Marc-André Bombeck 7a43131883 fix(db): track the time-zone pin's failure log as background work
The pin's SET on connect runs detached; its failure line now registers
with the background-task tracker like every other detached writer. The
coach thread test reads the new budget reset copy.
2026-10-11 13:18:08 +02:00
Marc-André Bombeck 576ff9a444 test(documents): pin that Open in new tab hands over the preview's bytes 2026-10-11 13:15:02 +02:00
Marc-André Bombeck dcb7a0bc09 fix(ai): count the daily AI budget on the account's own day
The token ledger keyed every day on UTC while the daily briefing and the
other per-day generations run on the account's local day, so east of UTC
the first generations after local midnight were refused against the
previous day's spent ceiling. The budget day is now the calendar day in
the account's time zone; each request still resolves it once and books
its reservation and reconcile on the same row. The medication extraction
reads relative phrases against the same local day, the operator's spend
card shows the operator's own day, and the limit copy says the budget
resets at midnight in the person's time zone.
2026-10-11 13:09:12 +02:00
Marc-André Bombeck 7d8ff0f2df fix(day): keep Escape in the date calendar from closing the whole day
On a phone the date's calendar is a sheet over the day's own sheet. In
the moment after it opens, before Radix has re-rendered both layers, the
day's sheet still holds the Escape listener, so an Escape aimed at the
calendar closed the day as well; the day-view spec caught it as a flake
on the phone viewport (4 of 10 runs locally). The day's sheet now ignores
an Escape that comes from inside another dialog, and the spec waits for
the layer handover instead of racing it.

The coach open-at-end spec read the thread's height as soon as an element
with the thread's slot was visible, but the empty state carries the same
slot. It now waits for the loaded thread's frame and all of its answers.
2026-10-11 13:09:12 +02:00
Marc-André Bombeck 334cb4e979 Merge branch 'v1422/search' into release/v1.42.2 2026-10-11 13:06:07 +02:00
Marc-André Bombeck a36e414895 feat(search): widen the clinical read for the in-app search and cover the palette end to end 2026-10-11 12:59:51 +02:00
Marc-André Bombeck 118494b91d test(rollups): stop the summary parity tests going red after midnight UTC
The two summary tests seeded readings counting back from the current
instant, a few more minutes earlier each, and compared the composed mean
with a plain AVG. Between midnight and about 01:15 UTC the earliest
minutes slid into the day before, a day gained a reading in another hour,
and for pulse, whose day is a mean of hourly means, the composed mean
left the plain AVG. The fixtures now count back from noon UTC yesterday,
and the expected mean is the one each type documents: AVG, or the mean
of hourly-mean days.
2026-10-11 12:54:37 +02:00
Marc-André Bombeck 11c55cc6f8 Merge branch 'v1422/notify' into release/v1.42.2 2026-10-11 12:47:15 +02:00
Marc-André Bombeck 8f99b81f5f test(logging): scrub the notification action token from logged paths 2026-10-11 12:46:43 +02:00
Marc-André Bombeck 274cf4900e test(notifications): pin the content preference in backups and the action error codes 2026-10-11 12:46:12 +02:00
Marc-André Bombeck 1b5225ba36 test(search): name the scan-cap mutation as it was run 2026-10-11 12:43:22 +02:00
Marc-André Bombeck 2dc313980b fix(google-health): let a progressing backfill slice end quietly and continue
Since the backfill resumes per collection, a slice that ends before the
whole history is in threw "backfill incomplete" so pg-boss retried it,
which logged job.run.failed at error and grew the queue's failure count
although nothing had failed. A slice that finished at least one more
collection now queues the next slice on the admission queue under its
own singleton key and completes. Only a slice that finished nothing new
still throws, so a backfill that is actually stuck keeps its error. The
import lock keys on the import identity without the slice number, and a
slice that finds the import already stamped walks nothing.
2026-10-11 12:42:58 +02:00
Marc-André Bombeck 1644acf443 feat(palette): list hits from the record under the local results 2026-10-11 12:39:54 +02:00
Marc-André Bombeck 12a8ab7c63 feat(documents): open a PDF or image original in a new browser tab
The document sheet gains an Open in new tab action for the types the
browser renders itself (PDF, JPEG, PNG, WebP, GIF), so a long report
gets the browser's own zoom and search instead of the small preview. It
opens the object URL the sheet already fetched through the app transport:
a plain navigation to the serve route carries no record-session
assertion and would be refused inside a shared record. Every other type
keeps the download only, and the serve route's matrix now pins SVG and
HTML to attachment as well.
2026-10-11 12:37:57 +02:00
Marc-André Bombeck fca1260b20 fix(oura): exchange and refresh tokens at Oura's current token endpoint
Apps from Oura's current developer portal receive codes from
moi.ouraring.com, and the documented api.ouraring.com/oauth/token answers
those codes with 400 invalid_request, so connecting failed and every
collection read came back 401. Token requests now go to
moi.ouraring.com/oauth/v2/ext/oauth-token first. A code works once, so the
documented endpoint is never asked first: a code whose callback names the
current server goes nowhere else, and only an older app's code or refresh
token gets one retry there after a 400, 401 or 404. The callback checks the
RFC 9207 iss parameter when present and refuses a foreign issuer.
2026-10-11 12:34:33 +02:00
Marc-André Bombeck 5435890275 test(notifications): cover tap targets, quick-answer tokens and the action route; document them 2026-10-11 12:33:02 +02:00
Marc-André Bombeck f65d25b0d0 fix(db): read the clock as UTC in raw SQL and prove it on a Berlin database
Every raw-SQL clock read against a timestamp column without a zone now
writes NOW() AT TIME ZONE 'UTC', so passkey and login challenges, rate
limits, provider pauses, coach usage stamps, tombstone and measurement
windows no longer pass through the session time zone even where the
per-connection pin cannot apply. The regression-window bounds stay naive
UTC timestamps instead of being turned into timestamptz. A structural
guard keeps bare NOW() / CURRENT_TIMESTAMP out of raw SQL outside a short
list of timestamptz targets. The integration test sets the database
default to Europe/Berlin and drives challenge claims, session expiry, the
native sign-in handoff freshness check and the rate-limit window through
the app's client, plus the challenge claim through an unpinned client.
Self-hosting docs describe the pin for an existing or shared Postgres.
2026-10-11 12:31:37 +02:00
Marc-André Bombeck 9b86ec2e10 test(search): cover the matcher, the scan bounds and the route 2026-10-11 12:29:41 +02:00
Marc-André Bombeck 40e66bf027 feat(notifications): add the content detail preference and the morning score 2026-10-11 12:27:53 +02:00
Marc-André Bombeck 1766ffa3d2 feat(search): search the record from one endpoint 2026-10-11 12:27:28 +02:00
Marc-André Bombeck f19dfc4572 feat(notifications): resolve a tap target per event and offer mood quick answers 2026-10-11 12:21:51 +02:00
Marc-André Bombeck 42ba471765 refactor(search): share the record matcher and give record candidates display data 2026-10-11 12:20:15 +02:00
Marc-André Bombeck cefc8c15c1 fix(db): pin every connection to UTC and say so at boot when it is not
Stored times are UTC in timestamp columns without a zone, and Postgres
reads now(), CURRENT_TIMESTAMP and offset-free timestamptz parameters
through the session time zone. On a database whose default is not UTC
every expiry check ran off by the offset. The Prisma pool, pg-boss, the
lock and the maintenance connections now send TimeZone=UTC as a startup
option, or SET it on connect when the startup options are disabled for
a pooler. A boot check reads SHOW TimeZone and warns on anything else.
2026-10-11 12:15:03 +02:00
Marc-André Bombeck 9aadb29876 Merge pull request #1199 from MBombeck/feat/strip-full-height
feat(shell): full-height docked strips, magnifier search, g p for the day
2026-10-10 20:42:22 +02:00
Marc-André Bombeck 39fc4e4da2 feat(shell): run the docked strips the full height and make them the only fold
The docked panels' strips now run from the top edge of the window to the
bottom, and the top bar ends left of them. The panel header no longer
carries a fold button in docked mode: the strip opens and closes the day
and, on /coach, the conversations; opening moves focus to the panel's
heading and closing returns it to the strip. The sheets keep a close
button, the day's now an X at the end of its header.

The command palette's door is a magnifier at every width, named Search,
with the shortcut in its tooltip. g p opens or closes the day like a
click on its strip, and below the docking width opens the sheet on the
last day or closes it; the shortcuts list shows it under Day.
2026-10-10 20:04:19 +02:00
Marc-André Bombeck 023115b05c fix(timeline): keep each mean's dot and value one unbreakable unit
The dot before a mean sat on the x-height middle, a little below the
text, and a wrapping line could leave it at the end of one line while its
value moved to the next. Each dot and value is now one inline-flex unit
that centres the dot on the text line and never wraps inside; the row
breaks only between units, a long name is cut short inside its own unit,
and the leading mean sign rides in the first unit. The legend and chip
dots no longer shrink beside long text.
2026-10-10 20:04:13 +02:00
Marc-André Bombeck b787bc3176 Merge pull request #1198 from MBombeck/release/v1.42.1
Release v1.42.1
2026-10-10 14:18:23 +02:00
Marc-André Bombeck 099ea514b1 chore(release): v1.42.1 2026-10-10 13:56:41 +02:00
Marc-André Bombeck 1afe0ee2e4 Merge pull request #1197 from MBombeck/fix/dashboard-top-card-gap
Keeps the gap under the dashboard's top card.
2026-10-10 13:54:47 +02:00
Marc-André Bombeck 020663d1f7 Merge branch 'main' into fix/dashboard-top-card-gap 2026-10-10 13:30:02 +02:00
Marc-André Bombeck 3ad82590c6 Merge pull request #1196 from MBombeck/fix/google-health-1194-1195
Refs #1194, #1195
2026-10-10 13:29:59 +02:00
Marc-André Bombeck 01aa4e5ba1 fix(dashboard): keep the gap under the top card 2026-10-10 13:21:59 +02:00
Marc-André Bombeck 46047e57c0 fix(google-health): keep long backfills out of reauth parking and settle overlapping sleep segments
A history backfill carried one access token through walks that outlive it.
After an hour every request answered 401, which classifies as a revoked
grant, so the account was parked at error_reauth with a refresh token that
was still valid, and every later run skipped it until a manual reconnect.
Running beside the hourly sync, the backfill also pushed the account over
Google's per-minute quota, and each 429 failed its whole collection.

- Walks take a token source that renews the access token before it expires
  and once more when Google refuses it mid-walk.
- A 429 is waited out (Retry-After, RetryInfo, else a doubling backoff) and
  the request repeated; it is never a reauth.
- error_reauth only short-circuits a run when the token endpoint refused the
  grant (needsReauth). A connection parked by a data request's 401 refreshes
  on its next run and clears itself when that run completes.
- One run per account at a time: syncs, the backfill and the sleep repair
  share an advisory lock. The hourly poll skips a held account, a manual
  sync answers 409 sync_in_progress, the backfill waits a bounded time.
- A backfill attempt records each collection it finishes cleanly
  (backfill_progress) and the next attempt resumes with the rest.
- The incremental watermark is stamped with the cycle's start, not its end.

Overlapping sleep segments that share a stage and an end share the natural
key (type, measuredAt, source, sleepStage). A re-score that moved one row
onto a sibling's slot threw P2002 and failed the sync on every run.

- A batch keeps one reading per natural key, chosen the same way each time
  (longer segment, then smaller externalId).
- A move that still collides is settled by reconcileExternalMeasurement,
  which retires the stale row, instead of failing the sync.
- Updates run before inserts, and the key-format rescue no longer re-keys a
  row this batch is moving.
- The sleep replace-by-window protects every session's fresh rows, so two
  overlapping sessions no longer tombstone and resurrect each other hourly.

Refs #1194, #1195
2026-10-10 12:59:25 +02:00
Marc-André Bombeck f0b4e902bb Merge pull request #1193 from MBombeck/fix/maintenance-shm
Refs #1192
2026-10-10 12:19:02 +02:00
Marc-André Bombeck a2ee79a746 fix(maintenance): run the measurement vacuum without a /dev/shm segment 2026-10-10 09:33:17 +02:00
dependabot[bot] e1eebebd1d chore(deps-dev): bump the development-weekly group across 1 directory with 3 updates (#1191)
Bumps the development-weekly group with 3 updates in the / directory: [@next/bundle-analyzer](https://github.com/vercel/next.js/tree/HEAD/packages/next-bundle-analyzer), [lefthook](https://github.com/evilmartians/lefthook) and [shadcn](https://github.com/shadcn-ui/ui/tree/HEAD/packages/shadcn).


Updates `@next/bundle-analyzer` from 16.3.3 to 16.3.8
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.8/packages/next-bundle-analyzer)

Updates `lefthook` from 2.2.1 to 2.1.17
- [Release notes](https://github.com/evilmartians/lefthook/releases)
- [Changelog](https://github.com/evilmartians/lefthook/blob/master/CHANGELOG.md)
- [Commits](https://github.com/evilmartians/lefthook/compare/v2.2.1...v2.1.17)

Updates `shadcn` from 4.21.4 to 4.21.3
- [Release notes](https://github.com/shadcn-ui/ui/releases)
- [Changelog](https://github.com/shadcn-ui/ui/blob/main/packages/shadcn/CHANGELOG.md)
- [Commits](https://github.com/shadcn-ui/ui/commits/shadcn@4.21.3/packages/shadcn)

---
updated-dependencies:
- dependency-name: "@next/bundle-analyzer"
  dependency-version: 16.3.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-weekly
- dependency-name: lefthook
  dependency-version: 2.1.17
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development-weekly
- dependency-name: shadcn
  dependency-version: 4.21.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development-weekly
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 02:07:07 +00:00
Marc-André Bombeck b10a1132e6 Merge pull request #1190 from MBombeck/deps/vitest-5
chore(deps): vitest 5
2026-10-10 03:37:09 +02:00
Marc-André Bombeck 91c358c56d Merge remote-tracking branch 'origin/main' into deps/vitest-5 2026-10-10 02:44:39 +02:00
dependabot[bot] c1309fba33 chore(deps): bump the production-weekly group with 2 updates (#1186)
Bumps the production-weekly group with 2 updates: [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) and [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react).


Updates `@aws-sdk/lib-storage` from 3.1146.0 to 3.1147.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1147.0/lib/lib-storage)

Updates `lucide-react` from 1.53.0 to 1.52.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.52.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.1147.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
- dependency-name: lucide-react
  dependency-version: 1.52.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production-weekly
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 00:15:35 +00:00
Marc-André Bombeck 6b11739a51 Merge pull request #1187 from MBombeck/deps/dotenv-18
chore(deps): dotenv 18
2026-10-10 02:12:14 +02:00
Marc-André Bombeck e4879522c0 Merge pull request #1185 from MBombeck/deps/dev-2026-10-09
chore(deps): weekly development updates
2026-10-10 01:43:51 +02:00
Marc-André Bombeck 013a3fb86a chore(deps): move the test runner to vitest 5
vitest and @vitest/coverage-v8 move from 4.1 to 5.0.3 together. Neither
config uses an option the major removed or changed: no projects, so the
new setupFiles concatenation does not apply; no browser mode; and the
integration config's forks pool, serial files and isolation keep their
meaning. Node 22.12 and Vite 6.4 are the new floors, and both are met.
2026-10-10 01:15:10 +02:00
Marc-André Bombeck 369775e039 chore(deps): move dotenv to 18
The breaking changes in 18 do not reach this repository: nothing preloads
dotenv with -r, nothing reads a .env.vault, and both entry points in use
(the default export in prisma.config.ts and the side-effect dotenv/config
import in the one-shot scripts) keep their behaviour. dotenv/config stays
quiet by default.

What does change is the load message, which now goes to stderr. Prisma
reads prisma.config.ts on every migrate deploy at container start, so
both loads there pass quiet: true rather than adding two stderr lines to
each boot. The image's /opt/prisma-cli install, which the config resolves
dotenv from, moves to the same 18.0.6.
2026-10-10 01:08:57 +02:00
Marc-André Bombeck 6d2f22e922 Merge pull request #1184 from MBombeck/deps/production-2026-10-09
chore(deps): weekly production updates, next and zod held back
2026-10-10 01:00:02 +02:00
Marc-André Bombeck 1d782d88e2 chore(deps): take the weekly development updates
@testcontainers/postgresql 12.2.0, @types/node 26.6.4, @vitejs/plugin-react
6.1.2, knip 6.40, lefthook 2.2, pdfjs-dist 6.4.299 and shadcn 4.21.

pdfjs-dist is a development dependency that the image ships: the runner
hoists the pinned copy for document rendering. 6.4 asks for
@napi-rs/canvas ^1.0.10, which the production updates bring, so the store
keeps a single canvas build for it. eslint-config-next and
@next/bundle-analyzer stay on the 16.3 line with next.
2026-10-10 00:49:17 +02:00
Marc-André Bombeck ff0a81154c chore(deps): keep next on 16.3.8, 16.4.0 breaks history-driven URL state
With 16.4.0 a document sheet opened through ?doc= stays open after Back,
and the [ / ] day keys sometimes leave ?day= where it was. Three end-to-end
journeys fail 3 of 40 repeated runs on 16.4.0 and 0 of 80 on 16.3.8 with
every other update in place, and CI hit the same journeys on both
viewports. 16.3.8 already carries every published security fix, so
Dependabot skips 16.4.0 for next and the two packages that move with it.
2026-10-10 00:36:36 +02:00
Marc-André Bombeck c2ac1428e5 chore(deps): take the weekly production updates, zod held on 4.4
Next 16.4.0, pg-boss 12.37.1, the AWS SDK 3.1146, @napi-rs/canvas 1.0.10,
@node-rs/argon2 2.2.2, @simplewebauthn/server 14.0.3, TanStack Query and
Virtual, lucide-react, mmdb-lib, nodemailer, otpauth, pg, radix-ui,
react-day-picker, react-hook-form, tailwind-merge and yaml.

pg-boss moves its schema from 42 to 45 (three additive migrations, nullable
columns and one partial index, no table rewrite); the worker applies them on
start as before. The retention test pins the installed version, so it moves
to 12.37.1 after re-checking every source line it asserts.

zod stays on 4.4.3. 4.5 requires seconds in `z.iso.datetime()` and 4.6
rewrites nullable unions in the generated JSON Schema into `type` arrays,
which reshapes the OpenAPI document and the MCP tool schemas. Dependabot
skips zod 4.5 and later until that move is made on purpose.
2026-10-09 23:03:03 +02:00
Marc-André Bombeck 90050815eb Merge pull request #1178 from MBombeck/release/v1.42.0
Release v1.42.0
2026-10-09 20:46:02 +02:00
Marc-André Bombeck e6a2429df9 fix(documents): keep four tiles across a 1440 px window with the day strip docked 2026-10-09 20:21:10 +02:00
Marc-André Bombeck 3becc235ae docs(release): retake the timeline images with the lab count as its own chip 2026-10-09 19:55:11 +02:00
Marc-André Bombeck d5a153893c Merge v142/release-images into release/v1.42.0 2026-10-09 19:44:09 +02:00
Marc-André Bombeck 27781ef12c Merge v142/last-chips into release/v1.42.0 2026-10-09 19:44:09 +02:00
Marc-André Bombeck d068d6c9e6 fix(day,timeline): put an intake's dose in its name and set a lab day's count apart
Under "What happened" an intake now reads "Ramipril 5mg" on one line, as
the same medication does under Running that day, instead of the name over a
muted dose line. A lab day's analyte count moves from the run-on second part
to the entry's tag: a chip of its own in the selection bar, brackets in the
screen-reader line and the hover title, the muted line in the chronicle.
2026-10-09 18:55:34 +02:00
Marc-André Bombeck 8a5589c32b docs(changelog): bring the v1.42.0 day view and timeline entries to the shipped state 2026-10-09 18:46:21 +02:00
Marc-André Bombeck 6a27bdfc2d docs(release): retake the v1.42.0 release images with the panel strips
The dashboard and the Coach now show the fixed strips at the right edge
with the day docked beside them, and the search field in the top bar.
Adds the command palette searching for sleep and the Health Score
history page. The timeline keeps All with one row per medication, five
value lines and a selected quarter. Each in light and dark, from the
demo seed on a local instance.
2026-10-09 18:44:33 +02:00
Marc-André Bombeck 1319c63825 docs(release): retake the v1.42.0 release images on the final state
Day view docked beside the dashboard with its running line, values and
the five scores, and as a phone sheet; the timeline at All with one row
per medication, five value lines and a selected quarter; the phone
chronicle; the air quality and pollen card; the readiness inventory;
and the Coach beside the docked day with the new-chat button. Each in
light and dark, from the demo seed on a local instance.
2026-10-09 18:30:29 +02:00
Marc-André Bombeck 891c85a82b chore(demo): seed readiness and health-score history and a start for magnesium
The day view reads readiness from the nightly COMPUTED recovery row and
the health score from the record of each local day; neither job reaches
back, so a fresh seed showed both for today at most. Mint the rows the
jobs would have written over the window, and give magnesium the start
date its running line needs.
2026-10-09 18:30:29 +02:00
Marc-André Bombeck be51db56bb docs(release): add the v1.42.0 release images
Day view docked beside the dashboard and as a phone sheet, the timeline
at All with a selected quarter, the phone chronicle, the air quality and
pollen card, and the readiness inventory, each in light and dark. Taken
from the demo seed on a local instance.
2026-10-09 18:30:29 +02:00
Marc-André Bombeck 1199e009b2 chore(demo): carry ragweed well into October in the seeded pollen 2026-10-09 18:30:29 +02:00
Marc-André Bombeck 145d138c66 chore(demo): seed a multi-year history for the timeline and day view
The demo account carried 90 days, which leaves the timeline's All zoom
and the day view with nothing to show before last quarter. The seed now
writes about four and a half years before that window: blood pressure in
bursts with a silent quarter and a thin one, weekly weight, phone steps,
watch resting heart rate, HRV and sleep from the third year, yearly
blood panels with HbA1c, lipids and hemoglobin, a resolved chronic
condition and three infections, ramipril with a stated start, a dose
change and a sick-day pause, a finished antibiotic course, flu shots and
a Tdap booster, visits, life events at day, month and year precision, a
sealed home location with a holiday period, and the stored weather, air
quality and pollen for every day. The timeline module is switched on.

All of it is invented and relative to the seed run; rows are written in
chunks so the seed stays quick.
2026-10-09 18:30:29 +02:00
Marc-André Bombeck 96ee0c7821 test(dashboard): assert the top card switch on its region, not on the day's digest 2026-10-09 18:23:48 +02:00
Marc-André Bombeck 25d0839296 Merge the right-side panel strips, lazy day panel and calendar, and the layout write lock into release/v1.42.0 2026-10-09 18:22:48 +02:00
Marc-André Bombeck d8fb922228 feat(shell): give each docked panel a fixed strip at the right edge
From 1280 px the day and, on /coach, the conversations each keep a
narrow strip at the right edge, open or not. A click on the strip opens
its panel left of the strips and a second click closes it; the strip
never moves. Strips start below the top bar's band, side by side,
conversations first. Below 1600 px one panel is open at a time.

The day slides like the conversations (width, 200 ms, none under reduced
motion), stays open through a page change instead of folding and
reopening, and does not slide in on arrival. Its content loads when a day
first opens, so the scores, sections and calendar leave the shell's first
load: every watched route falls back under its unchanged ceiling. The
aggregate cap is re-measured for the chunk double count this causes.
2026-10-09 18:22:11 +02:00
Marc-André Bombeck 41a7f4daea perf(ui): load the date field's calendar when it first opens
react-day-picker and the seven date-fns locales rode the first load of
every page with a date field, the dashboard and the settings sections
among them. The calendar now lives in its own module that the popover
loads on first open; the locale map moves beside it.
2026-10-09 18:22:10 +02:00
Marc-André Bombeck 86fa57d8bf fix(dashboard): queue concurrent layout writes behind a row lock
Parallel PUTs to /api/dashboard/chart-overlay-prefs from one account ran
in Serializable transactions, which aborted all but one with a
serialization failure and answered 500. Lock the user row with FOR UPDATE
instead, so each writer waits for the previous commit and reads it. The
layout reset on DELETE /api/dashboard/widgets takes the same lock.
2026-10-09 17:42:00 +02:00
Marc-André Bombeck 09f84ef418 test(coach): give the live scenario runner room on a loaded machine 2026-10-09 17:33:19 +02:00
Marc-André Bombeck 018841e6d6 Merge the final display polish into release/v1.42.0 2026-10-09 17:25:50 +02:00
Marc-André Bombeck dcfec1f35d Merge the final display polish into release/v1.42.0 2026-10-09 17:25:35 +02:00
Marc-André Bombeck ac95c9c7dd fix(day): Back on the same page still closes the day
Keeping the day open across pages read every loss of ?day= as a page
change, so Back past the step that opened the day on the same page
reopened it at once. A loss of ?day= with the page unchanged is the
person leaving the day, and it closes it as before; only a change of
page carries the open day along.
2026-10-09 17:16:10 +02:00
Marc-André Bombeck fbcf099718 test(analytics): expect the latest pulse as its latest day's value
The paged read still has to see every row; the latest pulse is now the
value of its latest local day, computed in the test through the same
day-mean helper.
2026-10-09 17:09:42 +02:00
Marc-André Bombeck 3a5953bda2 Merge the score history, score ranges and top card toggle into release/v1.42.0 2026-10-09 17:02:56 +02:00
Marc-André Bombeck b94101d0fe chore(i18n): drop the keys of the removed count line, usual label and open-day link
The day's count line, the usual-range label beside the focus value and
the selection bar's "Open <day>" link are gone, and so are their strings
in all seven bundles. The pulse latest-day read is listed in the dense
read guard with why it stays bounded: one type over one day, the rows the
day view reads anyway. The summaries test's database stub answers that
read.
2026-10-09 16:57:08 +02:00
Marc-André Bombeck 67d8657336 Merge keyboard shortcuts and the command palette into release/v1.42.0 2026-10-09 16:53:21 +02:00
Marc-André Bombeck 668f568289 feat(dashboard): let the top card be switched off
The layout settings gain a switch for the dashboard's top card (the Today
card above the tiles). Stored server-side on the layout blob as
`todayCardVisible`: missing means shown, only an explicit false hides it,
the default is omitted from storage, a save that omits the field keeps
the stored choice, and a layout reset shows the card again. Hidden, the
card leaves no skeleton or error card behind. The flag rides the
account-settings backup with the rest of the layout.

The field is deliberately not `heroVisible`, the retired opt-in hero's
flag that older stored layouts may still carry.

An end-to-end journey on its own account switches the card off and on
and walks the score histories' range tabs.
2026-10-09 16:52:39 +02:00
Marc-André Bombeck ebad3665fd perf(shell): load the keyboard shortcut list on first open 2026-10-09 16:52:34 +02:00
Marc-André Bombeck 8d1283a32a style: format the separator guard 2026-10-09 16:52:30 +02:00
Marc-André Bombeck 62140a4aee feat(insights): follow every score over time with one range selector
The health score had no history anywhere, readiness and the sleep score
showed a fixed sparkline, and only the stored nightly scores offered the
range tabs. Every score page now charts its course with the same
7 / 30 / 90 / All tabs, remembered per page, and each point opens its day.

- GET /api/insights/score-history?score=&days= serves the daily course of
  the health score (its stored day rows), readiness (the nightly blend,
  filed on its wake day) and the sleep score (each night against the
  trailing window ending on it), so a point equals what the day view shows
  for that day. MANAGE record read, module-gated for readiness and sleep,
  days 1 to 3650, additive.
- The health score's line breaks where its recipe changed (configuration
  or algorithm version); the usual range is formed from the newest side
  of the last seam.
- The range tabs move into one shared component used by every chart;
  recovery, stress and strain keep their measurement chart, now with the
  range remembered and no overlay dropdown.
- New page /insights/health-score, linked from the score panel and from
  the day view's score tile.
2026-10-09 16:45:39 +02:00
Marc-André Bombeck 27715c06e0 fix(timeline): a name right of its own bar, one band per pair of rows, one line per notable
A closed period's name could not stand right of its own bar: the room
check counted the bar it names as the next mark ahead, so "Away" went
above its bar and sat on top of the life event named on the row above.
The room ahead now counts only what starts to the right. A row's names
and the names above the next row's bars share one band a few pixels
tall, so whatever is placed on one is written into the other as well.

The phone chronicle listed "First value of a measure" twice on a day
when two metrics began on it, under one React key; it lists one line per
kind of notable a day.
2026-10-09 16:45:23 +02:00
Marc-André Bombeck b274ee51ad test(timeline): pin the medication rows, the open day, the point card and the page height
The journey now writes two medications through the real routes (one with
a start date and its course, one known only by an intake) and asserts
each is drawn on exactly one row; carries an open day from /timeline to
/labs and back after folding it; hovers and focuses a value point and
checks the card names that line alone and stays inside the chart; and
measures the page at 1280, 1440 and 1920 px, with and without the docked
day: no sideways scroll, and no height past the content beyond the
shell's own padding. Expanding all of a day's values scrolls the panel
and leaves the page height as it was. The "Open <day>" link the bar no
longer has is asserted absent; a chip opens the day instead.

Label texts carry their lane under their own attribute, so a lane
selector matches the lane and not its names.
2026-10-09 16:41:44 +02:00
Marc-André Bombeck b1a0598c9d fix(timeline): a point reads on its own, the page ends where its content does, an open day stays open
A point of a value line now shows a card for that line and that point
only: its name, the value with its unit, the stretch it is the mean of and
the readings behind it, in the tooltip the other charts use. Each point
has a 24 px HTML target over it: a pointer resting on it shows the card,
the keyboard reaches every line once and walks its points with the
arrows, a tap shows the card and a second tap (or its "View the whole
day") opens the day. The card is clamped to the chart's width. The SVG's
native titles are gone.

The two screen-reader tables beside the chart were hidden with sr-only on
the table element itself, where overflow: hidden does not apply: they laid
out at full size below the page and made <main> scroll sideways and past
its last card. They are hidden by a wrapper now, and the page ends where
its content does at every width, with and without the docked day.

The docked day stays open across pages. Leaving a page drops ?day= from
the URL, and the column folded to its edge on every navigation. The
remembered day now also remembers that it was left open (per account and
record, as before); a page reached without ?day= while it was left open
brings the day back, without taking focus from the page. Folding,
closing, Escape, a swipe down or opening the Coach's conversations
beside it is what closes it.
2026-10-09 16:37:14 +02:00
Marc-André Bombeck 715b733164 feat(shell): add a command palette for pages, settings, modules and actions
Cmd/Ctrl+K or the search control at the end of the top bar opens a
combobox over a client-side index built from the lists the navigation
already renders: pages, Insights pages, every Settings section and card
(deep-linked by a new anchor prop on SettingsCardHeader), the Appearance
sub-pages, module switches and a few actions. Entries follow the module
map, the Coach capability, the admin role and a shared record's grant;
inside somebody else's record no cards, switches or backup are offered.

Ranking is prefix, then word start, then substring, then a small edit
distance, then subsequence, with a short multilingual synonym list. The
empty state shows recent places (per account, in localStorage) and the
main actions. The palette is its own lazily loaded chunk; the top bar
shows a search field where it has room and a magnifier otherwise. The
module switch mutation moves into a shared hook used by the Modules hub
and the palette.
2026-10-09 16:35:21 +02:00
Marc-André Bombeck df67e285de fix(timeline): names that fit, links to each value's page, a calmer selection bar
Labels are placed without collisions: a name keeps a full gap before the
next mark on its line, is cut with an ellipsis when only part of it fits,
and is left out when not even a few characters do. A name above an open
bar ends before the bar's arrow head. All names are drawn after the today
and selection lines, on a halo of the card colour, so a line passes
behind a name instead of through it.

A value line's name wraps onto a second line instead of reading "Blood
pressure sy…", in every bundle, and it leads to the metric's page, as do
the value tiles in the day view. Both read one map from a kind of value
to its page (metricPageHref); a kind without a page stays plain text. The
links carry a 44 px target, a hover and a focus ring.

A life event's category is no longer run on after its title ("Parental
leave Family"): the chart names the event alone, the selection bar shows
the category as a small chip inside the entry, and the phone chronicle
puts it on the muted line below.

The selection bar shows each mean as name and value; the readings behind
it ("mean of 19 readings") are a hover title and a screen-reader detail.
The lone "Open <date>" link at its end is gone; a day opens from its
chips, Enter on the chart or a double click.

In the day view an event's time keeps to one line in every locale.
2026-10-09 16:25:48 +02:00
Marc-André Bombeck cd72317356 fix(day): one entry per medication, one meaning for "Pulse", a quieter panel
Running that day listed a medication, its course and its pause as rows of
their own, so Mounjaro read as "Mounjaro 7.5 mg" above "Mounjaro". The day
read now folds them into one entry per medication: its name with the dose
in effect that day (the last dose change on or before it), the course's
days on the second line, or the paused entry while a pause runs. The
Coach's get_day reads the same loader and gets the same single entry.

The dashboard's pulse widget showed the resting heart rate whenever one
was recorded but called it "Pulse", while the day view's "Pulse" tile
was the day's pulse; one day read 62 on the dashboard and 77 in the day.
The widget is now named by what it shows ("Resting heart rate" with
resting rows, "Pulse" without), and the pulse's latest value is the
latest day's pulse, the mean of its local hours' means through the shared
day-mean helper and the day view's own canonical-source pick, so both
"Pulse" figures are one number. The day view's pulse goes through the
same helper in the record's zone.

The count line above the day ("33 values, 3 entries") and the usual range
repeated beside the focus value ("Your 30 days before") are gone; the
sections and the value tiles already say both. An assessment's score and
band join with a comma, not a middle dot.
2026-10-09 16:16:42 +02:00
Marc-André Bombeck 9f3da5ac7d fix(timeline): keep every medication still taken on its row, and the chronicle honest
An active medication whose courses had all ended had no stretch left in
the current window: the courses decided when it was taken, none reached
into the window, and the medication's own open span was set aside because
it had courses. The row disappeared while the day view listed the
medication as running. The time since its last course now stands in as
its stretch, open to today.

The server cut a lane's items at the window edge, so a medication arrived
with only some of its courses and without the dose change that set the
dose of the piece running into the window. A medication touching the
window is now sent whole.

On a phone, every entry wears its lane's colour: the icon in the lane
colour, and a medication listed inside an illness keeps a dot on its own
rail instead of reading as the illness's orange. A month without an event
is no longer called empty when it had readings (its header stays and
carries the means) or while a medication was taken in it; only a month
with none of the three gets the "No entries" line.
2026-10-09 16:05:40 +02:00
Marc-André Bombeck 86a3856ae9 fix(i18n): drop spaced dashes as separators from every visible string
A dash between two parts of a sentence ("{what} — tomorrow", "Could not
record {name} — try again", "End {name} — …") stands in for a comma, a
colon or a full stop the sentence should say. Every such string in the
seven bundles now says it in plain words. A range keeps its unspaced en
dash ("70–100 mmHg", "{start}–{end}"); that is how a range is written,
not a pause, and the spaced range forms became unspaced ones.

Labels the components put together themselves follow the same rule:
aria-labels read "Move up: Ramipril", a medication and its dose read
"Ramipril 5 mg", page titles use "|", Coach prefills are two sentences,
and the API's user-facing error messages lost their dashes too.

The bundle guard now refuses " — " and " – " beside " · ", with an empty
allowlist, and a sibling guard walks src/components and src/app for a
string or template literal that joins its parts with a spaced dash.
2026-10-09 16:05:32 +02:00
Marc-André Bombeck 44841c65fc feat(shell): add global keyboard shortcuts and their help dialog
g then d/m/l/t/i/c/s opens a page through the same visible-destination
lists the navigation renders, so a switched-off module, a Coach without a
provider or a door a shared record does not cover stays shut. n opens the
capture picker, [ and ] step the open day, ? lists everything. Keys typed
into a field, held with Cmd/Ctrl/Alt, or pressed while a dialog or menu is
open are left alone; [ and ] still act over the day sheet and when the
layout needs Alt or AltGr to type them. The list is also reachable from
the sidebar account menu.
2026-10-09 16:03:54 +02:00
Marc-André Bombeck 1d080ee166 Merge the day picker, Coach new-chat button, day panel on /coach and text cleanup into release/v1.42.0 2026-10-09 15:11:24 +02:00
Marc-André Bombeck 4ca2334805 Merge the timeline rework into release/v1.42.0 2026-10-09 15:02:46 +02:00
Marc-André Bombeck 1440ad4301 feat(timeline): one row per medication, no cap on value lines, range popover
A medication arrived as up to four kinds of item (its own span, its
courses, its dose changes, its pauses) and was drawn item by item, so a
medication with a course read as two bars on two rows. Every item of one
medication now carries the medication's id as `group`, and the lane draws
one row per group: the courses are the stretches it was taken, each dose
change cuts them into pieces labelled with their dose, and a pause is a
gap in the same bar. The selection bar and the phone chronicle list one
entry per medication with the dose in the text.

The value series cap of six is gone. The response carries
`availableSeries`, every series the record can show, and the picker is
one compact "Values (n)" button whose menu lists them. Past the fifth
line the five data colours repeat; every line is named at its left.

Choosing a range opens a popover anchored to the "Range" segment (a sheet
on a phone) with "Apply", so the chart no longer moves; the URL keeps the
range as before. The selection bar sits on the card under a hairline
instead of a grey box, the legend explains the dashed stroke and the
hollow point, and no timeline text joins its parts with a middle dot or
calls an open span "ongoing".
2026-10-09 15:02:14 +02:00
Marc-André Bombeck 2ca60ab956 refactor(day): word the cycle line through a typed key map 2026-10-09 14:29:07 +02:00
Marc-André Bombeck 6033935449 Merge v142/day-scores into release/v1.42.0 2026-10-09 14:22:21 +02:00
Marc-André Bombeck 745bcb64b7 Merge v142/webhook-discord into release/v1.42.0 2026-10-09 14:22:13 +02:00
Marc-André Bombeck cda6c58d12 Merge v142/shots-fixes into release/v1.42.0 2026-10-09 14:22:13 +02:00
Marc-André Bombeck d52550cedb test(day): report the time the score reads add to a day 2026-10-09 14:21:10 +02:00
Marc-André Bombeck 32a9624c82 fix(day): raise the calendar as its own sheet on a phone
In the phone's bottom sheet the date sits halfway down the screen, and a
month of finger-sized cells below it ran off the bottom, Today button and
all. There the calendar now opens as a bottom sheet of its own and hands
focus back to the date when it closes. The docked column and the side
sheet keep the popover.

The conversations panel's fold and unfold buttons carry a title instead
of a tooltip: focus lands on them when the panel opens or folds, and a
tooltip opened by that focus took the next Escape for itself.
2026-10-09 14:18:35 +02:00
Marc-André Bombeck bde6880c3e fix(timeline): read the sleep line as time asleep per night
Sleep is stored one row per stage, and the timeline read it like any
other type: the live path took the mean of a day's rows and the long
path the rolled-up month of them, so IN_BED, AWAKE and every CORE
fragment counted as a night, and a quarter of seven-hour nights read as
under two hours.

The sleep series now reconstructs nights the way the sleep page and the
day view do (CORE + DEEP + REM of the night's winning writer, keyed by
its wake day) and averages the bucket's nights, on every window length;
the rollups are not read for it. The stage rows are read a year at a
time with a day's margin on either side, so no read holds a decade of
rows and no night is cut where one read ends. A long window is cached
per record like the other long series.
2026-10-09 14:11:46 +02:00
Marc-André Bombeck 6e2a8fcd9f feat(day): show the day's scores and the cycle day with its phase
The day view gains a scores section below the values: the health score as
recorded that day, the readiness the nightly job persisted for the night
that ended that morning, a device's own recovery, the sleep score of that
night and strain (computed, or the device's day strain on its 0 to 21
scale). Each tile carries the person's usual range over the 30 days before
and opens the score's own page; a score the day does not hold has no tile,
and a day without any has no section.

Every value comes from where its page reads its history: the stored health
score record, the persisted RECOVERY_SCORE and STRAIN_SCORE rows read on
the day they describe, the canonical recovery ladder, and the sleep score
computed over one bounded read of the 30 nights up to the day. The stored
score rows leave the values list of the day they are stamped on, so a
readiness no longer appears a day early among the readings.

Scores follow their own module (recovery, sleep; the health score is core)
and map to the whole record for sharing: a scoped grant sees them named as
not shared, a whole-record grant sees them. get_day sends them to the
Coach and the MCP endpoint by name and number, and the Coach exclusions
take out every score built on excluded data.

A running cycle now states its cycle day and phase ("Cycle day 12,
follicular phase"), computed the way the cycle calendar does. Only logged
cycles count, the next logged start closes a cycle, and an open cycle
stops counting past its typical length plus the grace window instead of
reading a day count nobody recorded.
2026-10-09 14:03:31 +02:00
Marc-André Bombeck b5f1077b94 fix(day): date the running list against the day on screen
DayRunning takes the viewed day, so a start in another year names its
year. The timeline's chronicle test reads the reworded gap line.
2026-10-09 13:53:33 +02:00
Marc-André Bombeck da92dabb55 Merge the separator sweep into the day and Coach polish 2026-10-09 13:44:58 +02:00
Marc-André Bombeck 08f532b428 feat(coach): New conversation in the chat, a foldable list, and the day beside it
New conversation leaves the conversations panel's header and floats in
the conversation itself: a round 56 px button with a plus, the floating
Coach button's face, named "New conversation". It sits in the composer's
row, so it always belongs to the chat column, never to the list or a
docked day beside it. Where the column is wide it takes the bottom right
corner beside the composer; where the composer fills the width (a phone,
the drawer, a narrow column) it rises above the composer's edge. The
drawer shows it once there is a conversation to leave, in place of the
plus in its header. An empty conversation shows nothing either way.

The docked conversations panel folds like the day: its header opens with
a fold control, and folded it keeps a 48 px edge whose button opens it
again (aria-expanded, focus moved between the two, Escape inside folds
it). The top bar's toggle remains for the sheet below 1280 px. On /coach
the day docks right of the panel. Below 1600 px only one of the two is
open: a day opening folds the list without touching the remembered
choice, and opening the list folds the day to its edge. From 1600 px both
stay open. Switching or starting a conversation keeps an open day in the
URL.

The empty conversation drops everything under the composer: the daily
seeded opener and the scope pill are gone, and with them the only reader
of GET /api/insights/coach/seeded-question and of the
coach.ambientSuggestions preference. The route, its OpenAPI entry, the
preference and its Settings switch go too.
2026-10-09 13:44:58 +02:00
Marc-André Bombeck d029e4e1dc fix(notifications): send content and text so Discord and Slack webhooks deliver
The generic webhook body carried title, message, eventType and priority
only. Discord refuses a body without content (400, code 50006 "Cannot
send an empty message") and a Slack incoming webhook one without text,
so the test button and every reminder failed against the targets the
settings card names.

The generic body now appends content (cut to Discord's 2000 characters)
and text, both the masked title and message on two lines, plus
allowed_mentions with an empty parse list so Discord resolves no
mentions. The four original fields keep their values and order for
Home Assistant and n8n rules. The Gotify body is unchanged. The card
names what works and shows a hint for the generic format.
2026-10-09 13:43:28 +02:00
Marc-André Bombeck 9d5a529d53 feat(day): open any day from a calendar on the header's date
The written date in the day's header is now the way to any other day. A
click (or Enter, or Space) opens a month calendar beside it: the day on
screen is selected, every day that holds anything carries a dot read from
GET /api/day/index for the month shown, nothing past today can be picked,
and a Today button sits under the grid. Picking a day swaps it in like a
step, without a new history entry; Escape closes only the calendar and
focus returns to the date. The header keeps its one line and gains no
icon; the arrows stay. The same popover serves the docked column and both
sheets, with 40 px cells for a finger.

On /coach the day now docks beside the conversation like everywhere else,
right of the conversations, instead of opening as a sheet. A neighbouring
panel can fold the docked day to its edge without moving focus
(yieldDay).

The day's count line reads "3 values, 2 entries".
2026-10-09 13:38:11 +02:00
Marc-André Bombeck 94542e7f2d fix(i18n): drop dot and dash separators from visible strings
Every bundle value that chained facts with " · " now reads as a phrase,
a comma list or a parenthesis, in all seven locales, and the em-dash
separators in the day, documents, correlation, sharing, environment,
vaccination and Apple Health import strings become sentences or clauses.

The day view's running list names the record first, with the dose in
the name and a life event's category as a chip, and puts a quiet day
count below: "Day 4, since 31 December" instead of a dot chain. Document
meta, location periods, vaccination rows and mood crosstab rows drop
their composed dots too.

A guard walks every bundle and fails on any " · " outside a reasoned
allowlist, and on an allowlist entry that no longer needs its place.
2026-10-09 13:37:14 +02:00
Marc-André Bombeck 59b072750a test(timeline): load the page once before rendering every zoom 2026-10-09 13:29:29 +02:00
Marc-André Bombeck 1d3b2d0ebe test(timeline): state the template-key guard's floor on what it found 2026-10-09 13:18:08 +02:00
Marc-André Bombeck 78aa80b2ea refactor(timeline): word codes through typed key maps, never template keys
A key built from a template, t(`timeline.zoom.${zoom}`), is invisible to
the call-site coverage guard, so a code without its message would ship as
the raw key and the gate stays green. The timeline and the day view now
word every code family (lanes, zooms, legends, readiness rows, life event
categories, precisions and errors, document kinds, notables, cycle flow,
allergy severity, screener instruments and bands, sports, functional
impact) through maps typed over the code's own union, so a new code does
not compile without its key.

Three checks hold it: one resolves every map value in all seven bundles
and pins the band and sport maps to their sources, one forbids template
keys in both trees (the one existence-checked readiness lookup is named),
and one renders the whole timeline page at every zoom and every bucket in
two languages and fails on any message key in the visible text.
2026-10-09 13:13:33 +02:00
Marc-André Bombeck 3f47224825 fix(i18n): key the catalog's cache by its content, not the release
The service worker serves the locale catalog's boot script cache-first,
and its URL carried the package version. Every build of one release
therefore shared one URL: an instance updated within a release (a beta,
a redeploy) kept the catalog of its first build, and every message added
since read as its raw key ("timeline.zoom.range", "timeline.selection.week")
or fell back to English.

next.config.ts now hashes the message catalogs at build time and the
layout keys the script by that hash, so a changed catalog is a new URL.
2026-10-09 13:13:26 +02:00
Marc-André Bombeck cd78415084 test(documents): check the archive grid under the flowing default
The vault runs its months side by side by default now, so a month is
marked above its first document and no longer has a heading row. The
grid check reads the arrangement and the month marker instead of an h2.
2026-10-09 13:04:27 +02:00
Marc-André Bombeck 4c06ecafe3 fix(dashboard): show the blood pressure axis in mmHg, not Hg
The dashboard's blood pressure chart passed `yAxisUnit="Hg"` beside
`unit="mmHg"`, a shortening carried since the first release, so every
tick read "154 Hg". The override is gone; "mmHg" is four characters and
fits the axis gutter like "bpm" and "mg/dL" do.

A unit with a space of its own ("/ 5") is now joined with no-break
spaces as well, so Recharts cannot wrap it either. A guard fails on any
`yAxisUnit` that is a cut-down form of the `unit` beside it.
2026-10-09 12:56:32 +02:00
Marc-André Bombeck 2638174b66 fix(timeline): word every code an entry carries, and read a pause as one
The timeline and the day view showed what the server sends as it was sent:
a life event's category as FAMILY, a visit's kind as PREVENTIVE, a
document's kind as DISCHARGE_LETTER, a trip and a cycle under their
sentinel labels, a lab day as a bare "5", a mood as SUPER_GUT, a
screener as PHQ9 with its band code. A pause read as "Ramipril" at its
start and "Ramipril ended" at its end.

Every surface that shows a timeline item (lane labels, hover titles,
the screen-reader table, the selection bar, the phone chronicle) now
reads it through one wording, item-words.ts: a code goes through the
bundle's existing labels, a lab day reads "5 lab values", and a code
the bundle does not know is left out rather than shown. The day view's
title and second line do the same per event kind (mood, screener and
band, sport, severity, visit kind or planned, dose number, intensity,
functional impact, document kind), and a medication that starts, ends,
pauses or resumes says so, in the day view and in the visit
preparation alike.

A pause reads "Ramipril paused" at its start and "Ramipril resumed ·
after 11 days" at its end, on the lanes, in the selection bar and in
the chronicle.

A guard renders a record with every item kind, every category and every
kind code on each surface, in two languages, and fails on any upper-case
code or sentinel in the visible text. The wire schema now says which
fields carry codes.
2026-10-09 12:53:56 +02:00
Marc-André Bombeck d8a3614120 Merge the fold provenance and timeline boundary fixes into release/v1.42.0 2026-10-09 12:02:59 +02:00
Marc-André Bombeck b47a39319c fix(timeline): read rolled-up months by their UTC date
West of UTC the local midnight of the boundary month falls hours after the
month's UTC bucket start, so the first rolled-up month went missing and the
first quarter was averaged over two of its months. The rollup window is now
compared by UTC date at both ends.
2026-10-09 12:02:25 +02:00
Marc-André Bombeck 814d3e0179 fix(fold): recompute a stored mean only where the record shows two folds
The fold leftovers were told from a person's deletions by syncVersion = 1,
but raw samples are raised in place before a fold takes them (the collision
merge, a changed Health Connect value). The repair then recomputed windows
without those samples and could overwrite a correct mean, also in windows
one run had folded whole.

A tombstone of fold age now counts as a leftover when it carries
syncVersion = 1 or shares its deletion instant with such a tombstone of the
same day; anything else of that age is ambiguous and leaves its window as
stored. Only a window taken in two parts (fold runs at least an hour apart,
or a run and later live samples) is recomputed.

Once the repair has been through an account, the purge may remove class-A
leftovers inside the horizon, so the folds' re-fold of a stored day or hour
leaves it as stored, and the purge takes the fold lock per account. The
fold-lock transactions get an explicit timeout and maxWait, since the lock
wait counts against Prisma's five-second default.
2026-10-09 12:02:25 +02:00
Marc-André Bombeck f8dab58bc5 refactor(timeline): drop an unused month helper 2026-10-09 10:56:44 +02:00
Marc-André Bombeck ecf27cd9bc Merge the air quality history hardening into release/v1.42.0 2026-10-09 10:56:35 +02:00
Marc-André Bombeck fe87af7234 Merge the delete journal scope, seasonal marker and admin-only geo state into release/v1.42.0 2026-10-09 10:51:44 +02:00
Marc-André Bombeck 999169679a fix(environment): harden the air-quality history backfill
- Place a day at the home only from homeSince on, through the same
  resolver the nightly fetch uses; a home without homeSince places no day.
- End a run at the first request the feed refuses (429, 5xx, network)
  with status error, no follow-up and no pg-boss retry.
- Fetch the weather of days without a row before their air quality, and
  keep the weather rows when the air-quality request fails, so no paid
  request is asked for twice.
- Create no row for a day the archive answer leaves out.
- Re-read the module, the account switch and the operator switch before
  every range.
- Check the history's own ceilings in the budget's locked transaction
  together with the charge, instead of reading usage first.
- Read the days with entries once per chain and hand them to the
  follow-up in its payload.
2026-10-09 10:49:31 +02:00
Marc-André Bombeck 470ddeedc4 fix(version): move the finer geo state to the admin status
geoLookup and geoProviderChosen on the public /api/version added one
more way to tell instances apart. They now live on /api/admin/status
(requireAdmin, cookie only) as geo { lookup, providerChosen,
providerHost }, which both admin status views read. offlineGeoEnabled
and geoProviderHost stay public as before.
2026-10-09 10:44:18 +02:00
Marc-André Bombeck 0ea8d929d2 fix(correlations): date the earlier-engine note from the instance's first new run
The note for records whose findings predate the seasonally adjusted
engine compared against a fixed calendar date, so findings an instance
kept writing under the earlier engine until its deploy did not count.
The first discovery sync of the new engine now stamps
AppSettings.correlationSeasonalEngineSince before it writes; without a
stamp every stored discovery pattern counts as an earlier finding.

A restore from a portable export reset each pattern's createdAt to the
restore day and dropped the note for good. createdAt now rides every
export, and a file without it falls back to lastComputedAt.
2026-10-09 10:44:17 +02:00
Marc-André Bombeck 587ea35d34 fix(coach): bind the delete journal to the account and record it was sent for
A tab outlives a logout, another login and a switch into a managed
profile. A delete re-sent in another scope met a 404 that read as
"already gone" and dropped the entry, or tripped the record fence and
toasted a failure for a row the person could not see. Entries now carry
their owner, only the current owner's are re-sent, and a session end
clears the journal with the rest of the session state.
2026-10-09 10:29:36 +02:00
Marc-André Bombeck c40b21de3d Merge the Coach header, timeline colours, selection bar and range into release/v1.42.0 2026-10-09 10:18:23 +02:00
Marc-André Bombeck b5e15fc105 test(timeline): wait for the values menu before reading its dots 2026-10-09 10:08:54 +02:00
Marc-André Bombeck 5f5ddd37a6 fix(timeline): keep "No entries" for a bucket with nothing in it
A bucket that holds a mean but no lane entry is not empty; the means
say what it holds. The bar opened on such a bucket by default and called
it empty beside its own values.
2026-10-09 10:05:06 +02:00
Marc-André Bombeck 57c44006ee Merge the document layout default and the air quality history into release/v1.42.0 2026-10-09 10:02:56 +02:00
Marc-André Bombeck 36dbfb8af7 fix(environment): fill a day before the home only inside a location period
The past-days backfill placed every day without a row at the current home,
also before the home was set. Weather and air of a place the person was not
at would flow into correlations, the Coach and the day view as fact. A day
before homeSince is now filled only when a location period covers it; with
no homeSince stored the home still covers every day. Days without a place
are not counted in the progress.
2026-10-09 10:02:38 +02:00
Marc-André Bombeck 61155d1856 feat(timeline): add a chosen range as a fourth zoom
Beside All, 12 months and 3 months the timeline takes a range of the
person's own choosing, from and to, through the app's date field. Each
change is clamped softly: never past today, never before the record's
first entry, never inverted, never longer than fifteen years. On a phone,
where the chronicle has no zoom control, a range button opens the same
fields with a way back to the whole record. A range with nothing in it
keeps its fields instead of the empty-record notice.

The zoom and the range live in the URL (zoom, from, to), so Back and a
bookmark return to them; a parameter that does not read falls back to
All. The API accepts zoom=range with both ends, at most fifteen years,
and picks the bucket by its length: quarters from two years, months from
120 days, weeks from 42 days and single days below, a new day bucket
whose dashed bridge spans at most three missing days. A range borrows
the grid and the keyboard steps of the fixed zoom nearest its length.
The selection bar heads a day bucket with that day.
2026-10-09 10:02:37 +02:00
Marc-André Bombeck f8557f57a0 feat(timeline): colour each value line and name the selection's bucket
Each value line takes the colour its type carries in the measurement
list (a --chart-N token); where two chosen lines would share one, or a
type has none, the later line in the menu's order takes the first free
data token, and a sixth line with none left falls back to the
foreground. The swatch beside the name, the line, the dashed bridge,
filled and hollow points and a ring on the selected bucket all inherit
one colour from their group. The menu, the selection bar and the phone
chronicle mark each value with the same dot. The colour map moves to an
icon-free module so the timeline does not pull the list's icons.

The selection bar is a cross-section of one bucket and now says which:
its head names the quarter, month or week in full, its entries are the
ones that touch that bucket, and each mean keeps its readings count.
Values go through the day view's formatter, so sleep reads as a
duration and the unit preference applies; the server sends canonical
units, which the old formatter took for display units. Before anyone
picks a day the bar opens on the newest bucket it has something for in
the requested window (documents, which it never lists, no longer count),
and a short line says how to pick another.
2026-10-09 09:48:53 +02:00
Marc-André Bombeck f788e2892d fix(coach): put New chat in the conversations panel header
The round button floated over the list's bottom right and covered the
last conversation, which the list offset with a pb-20 reserve. It is now
the first control of the panel header, left of the title, in the docked
panel and the phone sheet alike: 28 px beside a fine pointer, the 44 px
floor on touch, with its accessible name and a tooltip. The reserve goes
with it, and the header packs its touch-sized buttons tighter so the
title keeps its word at 390 px.
2026-10-09 09:48:46 +02:00
Marc-André Bombeck d81a6ddf25 feat(environment): fill air quality for every past day with entries
A background job fetches air quality, pollen and UV for every earlier
local day with a reading, mood entry, dose, symptom or workout: after the
update (boot and nightly discovery), when the home is set and when air
quality is switched on. A day with a weather row gets its air quality; a
day without one gets both, at the covering location period or else at the
home, also before the home was set. Days the source does not reach (before
2013 in Europe, before August 2022 elsewhere) stay empty and are not
counted.

Pending days are grouped by coarse location and cut into ranges of up to
90 days, joining days at most 14 apart, newest first. A run holds itself to
300 of the account's 400 daily calls and half of each instance window,
stores its progress on the user (migration 0391) and sends a follow-up while
it makes progress; a budget stop waits for the next discovery. The
Location & weather card shows the progress.

The air-quality client now asks pollen and dust from 2021 and skips the
request outside Europe before CAMS global starts.
2026-10-09 09:43:01 +02:00
Marc-André Bombeck 402c503781 Merge the seasonal adjustment note into release/v1.42.0 2026-10-09 09:30:11 +02:00
Marc-André Bombeck 898729d1a8 feat(insights): explain the shorter correlation list once
Since correlations are tested on seasonally adjusted residuals with an
effective sample size, an account with history sees far fewer
relationships, which reads as a defect without a word about it.

The correlations response now carries findingsBeforeSeasonalAdjustment:
true when the record kept a discovery pattern first stored before the
change, one indexed lookup on the pattern store. An account created since
can never qualify. The mood "What stands out" card shows a short note
for those records, closable, remembered per viewer like the AI setup
hint. Copy in all seven locales; OpenAPI regenerated.
2026-10-09 09:29:38 +02:00
Marc-André Bombeck 4c58274bed Merge the Coach conversation delete fix into release/v1.42.0 2026-10-09 09:14:42 +02:00
Marc-André Bombeck 359f11fe4d fix(coach): make a deleted conversation stay deleted
Deleting a conversation hides the row and sends the DELETE only after
the six-second undo window. The pending delete lived in a timer alone,
so a reload, a closed tab or an installed app sent to the background
inside the window dropped it without a request, and the conversation
came back. When the window did run out, the hide filter was released
while the paginated history still held the row, so it showed again
until the refetch landed.

Every surface that deletes a conversation (the Coach panel, the
conversations page, the settings memory card) shares one hook, and the
fix lives there:

- a scheduled delete commits early when the page is hidden, on
  pagehide and on unmount, and the request is sent with keepalive
- a committed row leaves every list cache, head list and every history
  search variant, before anything else changes, and stays hidden
- deletes not yet confirmed are kept in sessionStorage; the next page
  hides them and sends them again, so its list read cannot bring the
  row back before the old page's request lands; a 404 counts as gone
- a failed delete restores the lists and says so instead of the row
  quietly returning
- Undo after the commit reports false, so the rail does not reopen a
  thread that is gone
2026-10-09 09:14:10 +02:00
Marc-André Bombeck 8425e61a0d fix(coach): delete a conversation in one owner-scoped statement
A find-then-delete pair let two deletes of the same conversation both
find the row, and the slower one failed with a 500. A client that
re-sends a delete it could not confirm now gets 404 for the second
request, which it reads as already gone.

The integration test covers the thread, its messages and its document
links going with it, the document itself staying, the list no longer
carrying it, a foreign account getting 404, and the concurrent pair.
2026-10-09 09:14:09 +02:00
Marc-André Bombeck 5cf1a6c552 fix(timeline): drop the explanatory line under the life event sheet title 2026-10-09 09:10:34 +02:00
Marc-André Bombeck f6f0969da9 Merge the timeline buckets, gaps and series limit into release/v1.42.0 2026-10-09 09:04:11 +02:00
Marc-André Bombeck fcc6f40410 feat(timeline): keep gaps in value lines, mark thin means, allow six lines
Every bucket with a reading gets its point; one with fewer than three
readings is drawn hollow. A short gap (one quarter, two months, four
weeks) is bridged with a dashed stroke between the two real points; a
longer one leaves the line open. The selection bar and the value table
for screen readers name each bucket's readings and say "no value" where
there is none. The phone chronicle names each bucket once, at its newest
month. The value menu takes up to six lines. The legend's causality line
is gone with its keys in all seven bundles.
2026-10-09 09:03:42 +02:00
Marc-André Bombeck af982e3812 feat(timeline): average value series per quarter, month or week with reading counts
The server picks the bucket per zoom and sends it as `bucket`: quarters
for an `all` window over two years, months for `year` and a shorter
`all`, weeks for `quarter`. Each point carries the readings behind its
mean as `count`; a bucket without a reading stays absent. Means remain
the mean of day values, so pulse-like types keep the hourly-mean day.
`values` accepts up to six series.
2026-10-09 09:03:42 +02:00
Marc-André Bombeck fb7f1eea61 Merge the day running-items and docked-panel changes into release/v1.42.0 2026-10-09 08:54:46 +02:00
Marc-André Bombeck c3dff2c388 feat(day): collapse the docked day to an edge instead of closing it
From 1280 px the day sits beside the page, and its control at the top
left took it away entirely: after a page change there was no way back to
the day short of finding it again. The control now hides the day the way
a sidebar collapses. A narrow edge stays on the right, holding the date,
with a button that opens the last day again on this page or any other.
Focus lands on that button when the day collapses and on the day's
heading when it comes back.

The last open day is remembered per browser, bound to the account and
the record it was read in, and removed at every session end. The Coach
button keeps clear of the edge as it does of the open day. Sheets below
1280 px and on phones close as before, and Back still closes them.
2026-10-09 08:54:11 +02:00
Marc-André Bombeck 7340cd3ab7 feat(documents): run the vault's months side by side by default
An account that never picked an arrangement now sees the months flowing
across the width. The layout PUT stores only the fields the person chose,
so an untouched field keeps following the default while a saved choice
stays.
2026-10-09 08:50:20 +02:00
Marc-André Bombeck 0a6811c03f fix(admin): read a geolocation provider the operator named as a decision, not a warning 2026-10-09 08:38:58 +02:00
Marc-André Bombeck 09f94d3eac fix(day): run only records with a start of their own through a day
The "Running that day" list carried the profile facts (smoking status,
alcohol pattern, shift schedule) as running items, titled by their raw
codes and counted "day n" from the date the fact was filed. A person who
quit smoking years ago and entered it last week read "day 13". Those facts
describe the person, hold no start the person gave, and are not part of a
day; the lifestyle section and its running kind are gone from the day, its
index and the model-facing get_day.

The same false count reached a medication filed without a start date: its
creation date stood in as the start. `since` is now null there, with no
day count, and the entry's date only bounds which days show it. A life
event's category is worded in the reader's language instead of its code.
2026-10-09 08:38:01 +02:00
Marc-André Bombeck 88836452eb fix(day): show several readings of a type as their mean in the day's tiles 2026-10-09 08:29:12 +02:00
Marc-André Bombeck 6ff24cf2cf fix(coach): scroll the Coach with the same scrollbar as every other surface 2026-10-09 08:22:41 +02:00
Marc-André Bombeck 950395da42 Merge the fold horizon fix into release/v1.42.0 2026-10-09 01:07:54 +02:00
Marc-André Bombeck 40ac8fccb1 docs: state the horizon of the v1.42 fold repair
The repair covers the last 75 days of daily means and the 75 days before
the raw window of hourly means; older days stay as they are. The ops page
names the two new run counters.
2026-10-09 01:03:02 +02:00
Marc-André Bombeck cbc3b3118d fix(measurements): recompute a stored fold mean only where all its samples remain
The fold repair and the regular folds rewrote an existing daily or hourly
stats: mean from the samples still in the table. Fold tombstones are purged
75 days after they were written, so for an older window those samples can
be a fragment: a re-upload after an anchor reset, the rows of one later
fold run. A mean over them replaced a stored mean that had been taken from
the whole day, and on old days it moved a lot.

A stored mean is now recomputed only for a day that starts at or after the
tombstone horizon (foldConstituentHorizon): every fold tombstone of such a
day was written after dayStart + the fold threshold and is still inside the
retention, so the live samples plus the fold leftovers are the whole day.
That is the last 75 days for daily means and, since dense days fold 90 days
late, the 75 days before the raw window for hourly means. A window also
needs at least one fold leftover; without one the rows in hand are not
known to be all of it.

Beyond the horizon:
- The repair leaves the day as it is and counts it per type as
  windowsSkippedBeyondHorizon.
- The daily-mean consolidation and the dense hourly fold leave a day or
  hour that already has a live stats: row exactly as it is, the stored
  mean and its live samples alike (daysLeftAsStored, hoursLeftAsStored).
  Up to v1.41 the fold overwrote such a row with the mean of the live rows
  alone, and the first v1.42 cut with the mean of those plus whatever
  tombstones were left; both are a mean of part of a day. Dropping the
  live rows instead would lose samples that may not be in the stored
  mean, so they stay as synced. They are few, the folded_window guard
  refuses new ones, and the nightly pass walks over them without writing.
  A day or hour without a stored mean still folds as before.

Inside the horizon, a pass that recomputes a stored mean soft-deletes the
live samples it took in as fold leftovers instead of deleting them, so a
second pass computes the same mean from the same samples. Before, a fold
that deleted them left the repair a fragment, and the repair then rewrote
the mean from the tombstones alone.

The repair, the mean consolidation and the dense fold now take a per-account
advisory lock after the restore lock in every day transaction, and the
repair reads the day's samples inside it, so the two never compute the
same window from two views of its samples or write the same row at once.
The repair also stops at the fold boundary: a day the fold has not
finished is the fold's to complete.
2026-10-09 01:03:02 +02:00
Marc-André Bombeck bb5e439553 Merge the end-to-end and code-scanning fixes into release/v1.42.0 2026-10-09 00:42:36 +02:00
Marc-André Bombeck 312e5de2b2 fix(security): keep the theme boot script a literal and drop regex tag stripping
The boot script interpolated its two colours; it is one string literal now,
checked against THEME_COLOR by the script's own tests. The retired-route
guard escapes every regex metacharacter including the backslash, and the
two markup tests read text by walking the string instead of stripping tags
with a regex.
2026-10-09 00:41:56 +02:00
Marc-André Bombeck 8b5199ee00 test(e2e): tag the just-in sleep batches as a foreground sync
A batch without a trigger only marks the dashboard snapshot stale since
v1.42, so a snapshot an earlier spec left for the account painted the day
final before this journey's own night counted. Foreground evicts.
2026-10-09 00:41:56 +02:00
Marc-André Bombeck 3ad0607cec fix(settings): stack the coach nudge frequency row on a phone
At 16 px touch type the longest frequency option and its label no longer
fit one row inside a 390 px card, and the select pushed the page sideways.
2026-10-09 00:41:56 +02:00
Marc-André Bombeck 4a02b78f5a test(e2e): take the mood empty state's add control in the managed-profile journey
Since an empty mood list carries the one add control and the header drops
its own, the journey clicked a header button that unmounted as the list
resolved. Start the profile without entries, assert the empty-state add,
then the header add once an entry exists. Both carry a data-slot.
2026-10-09 00:41:54 +02:00
Marc-André Bombeck 10f17e71e3 docs(changelog): note the toolchain advisories closed in v1.42.0 2026-10-08 23:19:32 +02:00
Marc-André Bombeck 7903c3b74e fix(deps): raise source-map-js, postcss-selector-parser and smol-toml past their advisories 2026-10-08 23:05:29 +02:00
Marc-André Bombeck c52ad73ff7 chore(release): v1.42.0 2026-10-08 23:04:20 +02:00
Marc-André Bombeck 4641943481 Merge the interface review fixes into release/v1.42.0 2026-10-08 23:04:04 +02:00
Marc-André Bombeck 6208a5357a docs(api): describe the folded_window duplicate and the fold-free sync feed 2026-10-08 22:58:42 +02:00
Marc-André Bombeck f0fba23c34 refactor(insights): leave the arrange editor's sorted rows to the compiler
The draft now comes out of a render-time reconcile, so a manual memo
over it could not be preserved and the compiler skipped the component.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck 055cbcff0e fix(insights): count device strain by day, not by cycle
The band writes one strain row per physiological cycle, stamped at its
start, and a bedtime either side of midnight puts two starts on one
calendar day. The strain page counted those rows as days and drew an
extra sparkline point the recovery chart does not have. Collapse the
rows to one per local day at the day's mean, the chart's own value.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck facfcad107 fix(day): keep the pinned heatmap tooltip off the right edge
The tooltip shrinks to the room right of its clamped left edge and
wraps, so it never ran past the screen, but near the right border it
filled that room to the last pixel. Cap its width at the room less the
8 px gutter the clamp was meant to leave.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck b4e3e22d43 fix(ssr): clear the budget timer once a raced read answers
The mood and dashboard prefetches and the sign-in location lookup raced
their work against a bare setTimeout that was never cleared, leaving
one live timer behind per render or sign-in after the work had already
answered. A shared helper races the work and clears the timer.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck dd76b434c4 fix(documents): cancel a layout read in flight before flipping the switch
A first read of the vault layout that was still running when the switch
was flipped landed afterwards and painted the old layout until the save
answered, so the switch jumped back for a moment.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck 7525d2ed00 fix(settings): end the allergy and family-history cards in one action row
The allergy card had two action rows, the list's add button and the
free-text note's save, and the family-history card on a shared record
put its sentence about relatives after the list's actions. The add
button now rides the note's row before save, and the sentence moves
above the list.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck 04084dd6e2 fix(ui): link the adherence tile straight to the medication wizard and drop dead rail rules
The empty adherence tile linked to the retired /medications/new route,
which only redirects. The sidebar's short-touch rules keyed on a touch
screen at most 520 px tall, where the phone shell hides the rail, so
they could never apply.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck c1144b3c4c fix(ui): let a truncating label actually truncate
A span marked both shrink-0 and truncate never gives up width, so a
long source name on a collapsed priority group squeezed the metric
label at phone width, and a long month marker in the document flow
could overrun its cell. Both shrink now and cut with an ellipsis.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck 8f5b5d6e54 fix(a11y): keep disclosure panels in the tree so aria-controls resolves
The encryption column table and the source-priority groups, overrides
and device ladder rendered their panel only while open, so a collapsed
toggle's aria-controls pointed at nothing. The panels now stay mounted,
hidden and empty, while closed.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck ef1976832b refactor(css): move the shell and sheet rules into the components layer
Unlayered, the safe-area, keyboard and sheet-clearance rules outranked
every utility on the same element, so a later bg-* on the shell or a
bottom-* on a sheet would have been ignored without a trace. They now
sit under the utilities. The bottom sheet drops its bottom-0, which the
layered keyboard rule replaces with the same zero until the keyboard
opens; the keyboard rules that must win keep their !important. The
toast lift stays unlayered to outrank the toaster's own rules.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck 938b5151de fix(ui): drop the home-indicator padding of a bottom sheet on the keyboard
A bottom sheet standing on the on-screen keyboard kept its safe-area
inset as bottom padding, leaving about 34 px of empty space between
its last row and the keys on an iPhone.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck 6b41c11f30 fix(pwa): set the status-bar colour with the theme before first paint
The theme-color tags were rewritten to the app's theme only after
hydration, so with a light system and the app in dark mode the status
bar stayed light on a cold start. The existing boot script now sets
them along with the theme class, and once more after parsing in case
the tags stream in after it.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck ad620bcf80 fix(pwa): keep the new worker's precache when loading a new version
The reload cleared every HealthLog cache after asking the worker to
update, but the update resolves as soon as the new worker starts
installing. Its fresh precache of logos and favicon was deleted with
the old ones, so the installed app lacked them offline. Leave the
target release's caches in place.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck 58592e4822 fix(day): keep edge days and single readings in the dots under a time axis
A time-axis chart drawn from real instants tested each day's noon
anchor against the readings themselves, so the first day dropped out
when its reading came after noon UTC, the last when it came before, a
single reading never got a dot, and the span was cut in UTC while the
days were local. The row now spans the points' own calendar days, as
the chart hands them to its day links.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck f38d8a5803 fix(day): word the cycle and travel titles by record kind, not by text
The day view translated any title reading "cycle" or "travel", so a
medication or a document with that name showed up as the localised
cycle or travel label.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck 798fae747f fix(measurements): drop the bare separator before a unitless row's date
A score or a rating has no unit word, but the phone row still appended
the separator, so its meta line opened with a stray dot.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck dcbd564962 fix(sources): stop listing a source whose readings were all deleted
The in-use probe matched tombstoned readings, so a source stayed marked
on the priority ladder after every reading from it was deleted. Both
the read and the save response of the source-priority route go through
this one probe.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck 27dfc6a617 fix(ui): scroll to a deep link's target once a settling body reveals
A body held back until its reads settle was hidden when the page
arrived, so a link such as the pill-order anchor in the insights
settings had nothing to scroll to and the page stayed at the top.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck ecf56c6a1f fix(a11y): give the adherence and efficacy charts a keyboard way to their days
Both charts open a day on a click or a tap but carried no data table
and no day link, so a keyboard or screen-reader user could not reach
the days at all. Render the chart's points as a data table with day
links, and have the coverage guard require that way on every
day-linked chart or name where it lives instead.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck efbaf02335 fix(insights): settle the stat strip when its chart fails to load
The strip holds min, max, median and mean until the chart reports its
visible window. A chart chunk that failed was caught by the boundary
and never reported, so the cells stayed skeletons although the summary
was already there. The boundary now settles the strip with no window.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck 78b0cc06e2 fix(documents): keep the bulk bar clear of the bottom bar on a phone held sideways
The bar dropped to the page edge at md, but the bottom bar follows the
shell and stays on a touch screen held sideways, which left the action
row, Delete included, half under it. Key the drop on shell-desktop and
include the home-indicator inset.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck ca47077944 fix(pwa): lift toasts above the bottom bar wherever the phone shell shows it
The toaster's mobile offset applies below 600 px only, while the bottom
bar follows the shell up to md and on a touch screen held sideways. In
between, the update toast and its reload button sat over the tabs.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck a5546507fa fix(insights): keep a pill save when the overview arrangement saves after it
The arrange card sent the tile list it copied on mount, with a token
read fresh from the cache, so a pill-order save made beside it was
overwritten without a conflict. Send the current server tiles, and let
an untouched section draft follow the server copy so the pill
manager's ECG toggle no longer reads as a pending edit.
2026-10-08 22:57:47 +02:00
Marc-André Bombeck 32cb75c1aa fix(db): let the maintenance job run behind a pooler that refuses startup options 2026-10-08 22:57:30 +02:00
Marc-André Bombeck 0d368170f3 Merge the remaining review fixes into release/v1.42.0 2026-10-08 22:53:07 +02:00
Marc-André Bombeck 38a69ee950 test(sharing): count the MANAGE matrix without the life-event handlers
Life events are owner-only, so their three MANAGE handlers leave the
admitted inventory and the integration matrix's driver counts with it.
2026-10-08 22:37:57 +02:00
Marc-André Bombeck 8ac64c3e4f chore(release): empty the release-pending lists for v1.42
The lab unit conversion moves to the next release, so its note string
leaves all seven bundles and the i18n pending list. The three columns
that hold the conversion provenance stay (migration 0385, nothing writes
them yet); the backup now carries them both ways, so the round-trip
pending list is empty too and the conversion needs no backup change when
it lands.
2026-10-08 22:37:57 +02:00
Marc-André Bombeck 055c48a3fe fix(handover): show the link holder display names only
The claim preview is anonymous: whoever holds the link has proved
nothing yet. It listed each guardian's grant id and fell back to their
login name. It now carries the display name alone, null when there is
none, and the page says "a guardian" for that.

The notifications to the guardians fell back to the new owner's login
name when the record had no display name. They now use sentences about
the person the guardian looked after instead.
2026-10-08 22:37:57 +02:00
Marc-André Bombeck f71becef29 fix(vaccinations): answer 409 to a concurrent duplicate vaccine name
Two adds of the same custom vaccine name both passed the name lookup,
and the loser failed on the unique index with a 500. A unique violation
on the add or the rename now answers the same 409 the lookup gives.
2026-10-08 22:37:57 +02:00
Marc-André Bombeck 7c6510eba9 fix(db): let a PgBouncer host turn the session startup options off
The statement timeout and, since v1.42, work_mem reach Postgres as the
libpq options startup parameter, which PgBouncer refuses unless it is in
ignore_startup_parameters. A host behind it could drop the string by
setting the timeout to 0; work_mem is now always sent, so that no longer
worked. DATABASE_SESSION_OPTIONS_DISABLED sends no session settings,
documented in the example env, the env manifest, the compose whitelist
and the scaling guide.
2026-10-08 22:37:57 +02:00
Marc-André Bombeck f950dffad4 fix(sharing): keep life events to their owner
Life events rode the profile sharing domain, so a profile share, a
legacy whole-record share and a MANAGE grant all read them, and MANAGE
could write them. No share level was ever worded for them, so in this
release they are the owner's only.

The life-event routes take a bare requireAuth, which refuses under an
acting-account switch. The day section and the timeline's life lane map
to no domain, so a delegate sees the section named not_shared and never
gets the lane, its readiness row or the day index marks. The profile
consent copy no longer names life events, the sharing guard and the
mutation inventory drop the four entries, and the OpenAPI descriptions
say owner-only.
2026-10-08 22:37:57 +02:00
Marc-André Bombeck e2ed62c74f docs(api): publish the environment backfill limits 2026-10-08 22:37:56 +02:00
Marc-André Bombeck 98183700ce fix(handover): keep a claimed handover when its Guardian deletes their account
created_by_id cascaded, so a Guardian who deleted their own account took
every handover they had issued with them, including a claimed one whose
decision the new owner had not made yet. The claim had already applied
the proposal, possibly MANAGE for a co-Guardian, and the owner lost the
one place that asks them to confirm it.

The column is nullable now and cleared on delete (migration 0390). An
unclaimed link without a creator is refused like any link whose creator
lost access. A decision that names a Guardian whose account is gone is
refused with 422 instead of failing on a grant for a missing account.
2026-10-08 22:37:56 +02:00
Marc-André Bombeck c7cee9c8a7 fix(coach): hold get_day to the Coach exclusions and keep free text out
get_day read through the day loader rather than the snapshot, so it
ignored the person's Coach exclusions: a person who kept mood or
medications from the Coach had both sent to the model as soon as it
asked for a day. Each exclusion token now maps to the day sections and
measurement types it covers; the sections are not loaded and the types
are dropped from the projection. A guard runs every token through the
executor.

A dated document reached the model under its title or file name, and a
visit under its reason or the practitioner's name. Both now carry their
kind. The remaining titles are fenced as the person's own text in the
Coach, as the MCP endpoint already did.
2026-10-08 22:37:56 +02:00
Marc-André Bombeck 509166afc4 fix(environment): keep one account from draining the Open-Meteo budget
The backfill route shared the analytics-read bucket and sent every
explicit range without a key, so one account could queue two-year
backfills until the instance-wide daily budget was gone for everyone.

The route now allows three backfills a rolling hour per account (429
environment.backfill_rate_limited) and one queued backfill per
twenty-minute slot, keyed on the account (409
environment.backfill_pending). Every fetch made for an account is also
charged to that account's own share of the daily budget, one twentieth
of it, so a refused request ends the run as budget-blocked and the
nightly run continues.
2026-10-08 22:37:56 +02:00
Marc-André Bombeck 78cde56542 fix(environment): fail closed on a travel period that does not open
A location period whose sealed location this host cannot open was
dropped from the resolver, so its days fell through to the home and the
next refresh overwrote the stored travel days with the weather at home.
Those days are now skipped and the run logs an error signal.

A restore now opens every sealed location once. One that does not open
is kept sealed, so the refresh keeps skipping its days, and is named in
the restore report under a new catalogue.
2026-10-08 22:37:56 +02:00
Marc-André Bombeck 342d73fb36 fix(google-health): read a full day of intraday samples once per local day
A watch worn without its phone reaches Google's cloud hours after the
samples were taken. The two-hour intraday overlap is measured from the
end of the previous clean cycle, so anything arriving later than that
was never fetched. The first cycle of each local day now reads intraday
samples with the full day of overlap; later cycles keep two hours.
2026-10-08 22:37:56 +02:00
Marc-André Bombeck a9bf72a7ce Merge the last UI round into release/v1.42.0 2026-10-08 21:12:05 +02:00
Marc-André Bombeck fc2753ef79 Let the delegated-write journey add through the empty list's control
An empty measurement list carries its add action in the empty state,
not the header, so the write-grant journey waited on a header button
that never appeared. It takes whichever add control the page shows, the
way the other add flows already do.
2026-10-08 21:10:06 +02:00
Marc-André Bombeck 01f3a8b875 Check the account page's language select where it now lives
The mobile consistency check pinned the language select to the date of
birth's grid row. It now asserts the select sits in the display card,
outside the profile form, while the date of birth stays in the form.
2026-10-08 21:10:06 +02:00
Marc-André Bombeck 313acdf304 Size the source-priority skeleton to every metric line
The loading state now reserves one collapsed line per metric, the upper
bound of the loaded list, so the action row does not move down when the
ladders arrive.
2026-10-08 21:04:09 +02:00
Marc-André Bombeck d99306275c Drop the settings section descriptions nothing reads
Sixteen settings.sections.<slug>.description strings per locale had no
call site: the shell paints each section's subtitle, and no literal or
key template builds a section description. The reverse-coverage guard
counted them as reached only because its suffix arm accepts any
`${prefix}.description` template next to a sibling key such as
.title.
2026-10-08 20:55:01 +02:00
Marc-André Bombeck 8ff35231f8 Write English navigation and insights chip labels in sentence case
Settings, admin and main navigation read "Data & privacy", "Export &
import", "Health score", "API & tokens", "MCP connector", "System
status", "Danger zone" and "Admin console", and the insights metric
chips drop their title case ("Blood pressure", "Body fat" and the rest),
matching the labels around them. The other locales already followed
their own conventions; the API page title is translated where it had
been left in English.
2026-10-08 20:53:18 +02:00
Marc-André Bombeck 0fd1168fd9 Pin the connector settings at one fewer success tint
The endpoint card's green Enabled badge went with the status badge
that repeated its switch.
2026-10-08 20:53:17 +02:00
Marc-André Bombeck a589e15f86 Name the arrangement settings hub "Layout"
The hub arranges and picks what the dashboard, insights and each module
show and holds no theme control, so "Appearance" promised something it
does not have. It reads "Layout" in every locale, and its subtitle says
what it does. In German "Darstellung" now names only the account page's
language-and-units card.
2026-10-08 20:47:55 +02:00
Marc-André Bombeck f648168266 Derive the privacy page's stored-data list from the module registry
"What's stored" was six hand-kept lines that never named documents,
vaccinations, the cycle or workouts. It now names every module that
keeps records, read off the registry so a new module joins without an
edit, beside fixed lines for measurements, the profile, integration
tokens and account security, in foreground text since it is the content
of the card.
2026-10-08 20:46:51 +02:00
Marc-André Bombeck c4272343e3 Collapse the admin per-column encryption table by default
A fresh install lists well over a hundred encrypted columns, nearly all
empty, and the open table pushed every other encryption card off the
page. The card now opens on a toggle that names how many columns hold
rows, lists only those when expanded, and offers the full list behind
a second toggle.
2026-10-08 20:45:31 +02:00
Marc-André Bombeck 834803ff21 Page the admin app logs fifty rows at a time
The buffer answers with up to ~500 events and the table painted all of
them. It now shows fifty per page with the login overview's pager,
resets to the first page when a filter changes, and clamps the page
when a refetch returns fewer events.
2026-10-08 20:42:35 +02:00
Marc-André Bombeck 2d79b80298 Give the account page's save-on-change preferences their own card
Language, units, glucose unit and the hour and date formats each save
the moment they change, yet sat inside the profile form as if its Save
covered them. They move to a Display card with no save button; the
profile card keeps its fields, the timezone (which saves with the form)
and its Save.
2026-10-08 20:41:50 +02:00
Marc-André Bombeck 51374274aa List only the sources an account uses on the source-priority page
The read and save of /api/auth/me/source-priority now carry inUse: the
ranked sources the account has connected or holds at least one
measurement from, one index probe per source. The settings page lists
only those, hides a metric nothing delivers, and opens one metric group
at a time, the first by default, with the winning source named on each
collapsed line. The ladders stay whole: moving a source swaps it with
its nearest listed neighbour, so a hidden source keeps its slot.
2026-10-08 20:40:39 +02:00
Marc-André Bombeck c816946140 Tidy the token, connector and share-link settings cards
The API tokens card names every kind of token it lists and reads in
sentence case. Each ingest card and the connector card fold the
shown-once note into the paragraph about what the token can do, and
their name fields suggest a fitting example instead of one phone
shortcut everywhere, short enough to stay whole on a phone. The
connector card drops the status badge that repeated its switch. Share
links get their own card next to the one that creates them.
2026-10-08 20:35:56 +02:00
Marc-André Bombeck c90d28e597 Name the admin account and invite cards apart from their pages
The users page card is now "Accounts" and the invites card "Invite
links", so neither repeats the page title above it, and the token
overview reads "API tokens" in sentence case. The role filter pills
stay neutral when selected, and the three icon tools on a phone row
wrap as one group instead of leaving the sign-out icon on its own line.
2026-10-08 20:35:56 +02:00
Marc-André Bombeck 161963c82b Put the passkey and security-key add actions in the card's action row
Both cards now end in the one right-aligned action row with the add
action as the card's primary, like the authenticator card's set-up
button, and both show the same plain empty state. The error line moves
above the row so nothing follows it.
2026-10-08 20:35:55 +02:00
Marc-André Bombeck f363405df0 Merge the settings and admin UI round into release/v1.42.0 2026-10-08 20:21:39 +02:00
Marc-André Bombeck ee16d3ca72 Update settings and admin copy
New card titles and one-sentence descriptions, sentence case for admin
titles, a current anamnesis subtitle, and the keys the pass retired.
2026-10-08 20:20:04 +02:00
Marc-André Bombeck f4d2ec8988 Tidy admin settings cards
Offer every shipped locale as the server default language, move the app
log refresh out of the header status slot, let filter selects fill their
grid cells, name the coverage table as a focusable region, hide the
legacy-key badge when only one key exists, keep the encryption cards
still while they load, and stop repeating the page title in card titles.
2026-10-08 20:20:03 +02:00
Marc-André Bombeck 37f922af97 Fix settings field rows, defaults and narrow layouts
Module rows drop the field-label colon and inset, the reminder number
fields align, target defaults print at their own precision, the
appointment offer wraps instead of squeezing to one word per line, the
export format legend matches its neighbour label, and the profile-photo
explainer reads as body text.
2026-10-08 20:20:03 +02:00
Marc-André Bombeck 2dda933d77 Align layout settings cards and the insights arrange editor
The insights arrange list becomes a Settings card with its own header and
a Save that waits for a change; the self-link back to the page it sits on
and the focus grab on load are gone. The medications view card takes the
header shape the labs and documents cards use, the order card shares
their title and loads with row placeholders, and the documents view card
uses their icon.
2026-10-08 20:20:03 +02:00
Marc-André Bombeck 991764b0c8 Give settings cards one action row at the foot
The TOTP enable, the source-priority reset, the biomarker and record add
actions, and the about-me, profile-facts, emergency and condition saves
move into SettingsCardActions, right-aligned and last in the card. The
emergency Save waits for a change like its neighbours, an empty list
leaves its add action to the empty state, and the source ladder keeps
its up/down buttons side by side on a phone.
2026-10-08 20:20:03 +02:00
Marc-André Bombeck 97e4dd4c05 Split the AI provider settings into one card per concern
The page was one card titled like the page, holding seven grey panels
with three primary Saves, each left-aligned mid-card. Provider, fallback
chain, response timeout, document reading, consents and the check row
are separate Settings cards now, each with one action row at its foot.
The chain rows keep the name on its own line on a phone, and the cards
below the provider card wait for its reads so they do not jump.
2026-10-08 20:20:02 +02:00
Marc-André Bombeck 8fc467f8c5 Merge the second UI round into release/v1.42.0 2026-10-08 20:05:44 +02:00
Marc-André Bombeck a1a0afe675 Let the vaccination journey wait for its list before adding
The header add disappears once an empty Impfpass has loaded; the
journey now waits for a record or the empty state and clicks whichever
add control is on screen.
2026-10-08 20:03:47 +02:00
Marc-André Bombeck 276cac1d5b Point the e2e add flows at whichever add control a surface shows
A list that is empty now carries its add action in the empty state
instead of the header, and on the phone shell the dashboard's add opens
the capture sheet. The overflow, mood timestamp, measurement flow,
onboarding tour and delegated-write journeys accept either control; the
measurement list's empty-state add gets a stable data-slot for that.
2026-10-08 19:51:16 +02:00
Marc-André Bombeck 5be7a9c242 Label the privacy page's sign-in link in German like its headings 2026-10-08 19:37:31 +02:00
Marc-André Bombeck a5e947ce97 Keep the unit and the time whole when the phone meta line wraps
'vor 10 Stunden' broke across two lines; the unit and the time now
wrap as units, the source badge after them.
2026-10-08 19:37:02 +02:00
Marc-André Bombeck 0bcf465e38 Close the off-switch token comment
A broken comment terminator swallowed the light theme's off-track
value, leaving the track unset.
2026-10-08 19:36:02 +02:00
Marc-André Bombeck 3b4d031e1e Lay the medication tabs out three-up on a phone
Wrapped, the last two tabs stretched across the second row.
2026-10-08 19:34:16 +02:00
Marc-André Bombeck 7d3ee68bf1 Describe a device-served strain score by the device line alone
The proxy's TRIMP method sat above the device note and described a
number the card was not showing.
2026-10-08 19:33:51 +02:00
Marc-André Bombeck dd49ec63a6 Sort the shell-variant classes the way the formatter orders them 2026-10-08 19:20:49 +02:00
Marc-André Bombeck e227f8e4f3 Give off switches and disabled primary buttons light-mode contrast
In the light theme an off switch's track was the input grey at ~1.4:1
on the card, and a disabled primary button was white on a half-opacity
violet at ~2:1. An off track now has its own token at ~3.5:1, and a
disabled primary button a pale violet fill with a dark violet label at
~5.5:1. The dark theme keeps its look through the same tokens.
2026-10-08 19:17:50 +02:00
Marc-André Bombeck 28458448c2 Bring the day surfaces into one language
The cycle and travel rows of a day read their server sentinels
('cycle', 'travel', a raw flow level) instead of words, so a day said
'cycle · Tag 8'. They are worded in the reader's language now. The
date in the phone measurement row and the workout row wrapped to
'vor 9 Stunden…' because the text column shrank to its value; it takes
the row's width and wraps instead of truncating. The intraday pulse
card drops its coloured identity edge, takes a title that says what it
shows, and says there is not enough for a course instead of drawing one
dot across 24 hours. The day body's top inset moves from the banned
pt-5 to pt-4.
2026-10-08 19:14:22 +02:00
Marc-André Bombeck e9a5614733 Colour insight series from the chart palette, not the status tokens
Metric pages coloured their series with success, info, warning,
destructive or a raw palette yellow, picked per page, so a steps line
read as 'good' and a resting-pulse line as an alarm. Series now take
the chart tokens by hue family (chart-2 green, chart-3 pink, chart-4
cyan, chart-5 orange), and the systolic/diastolic pair and the pulse
pair stay paired. Status colours remain where they carry meaning:
target bands, time in range, mood score scales. The mood tag scale
drops its raw palette reds and greens for the status tokens, which are
tuned for the light theme.
2026-10-08 19:08:51 +02:00
Marc-André Bombeck a1b46eb2f4 Write workout durations and pace the reader's way
Workout durations read '38m 00s' and '1h 02m' in every locale, the pace
'5:51 /km', and the detail header printed its raw source key. The
detail now shows a clock reading with the locale's unit ('38:00 Min.',
'1:02:05 Std.'), the list and the dashboard tile the shared localised
minutes label, the pace 'min/km', and the header the source's name.
2026-10-08 19:07:37 +02:00
Marc-André Bombeck 8302f5f62a Write mood insight figures with the reader's decimal separator
Averages, deltas and correlation figures on the mood insights went
through toFixed and read '3.4' in German. They use the locale formatter
now, and the whole-number crosstab cells pick up thousands grouping.
2026-10-08 19:04:30 +02:00
Marc-André Bombeck 7d738c0e49 Lead the meta line with the unit in the phone measurement list
On a phone a long unit beside the value ('12,5 Atemzüge/min') pushed
the row's controls. The value stands alone and the unit opens the muted
meta line beside the time and source, where meta belongs.
2026-10-08 19:03:32 +02:00
Marc-André Bombeck 5850036527 Number the Telegram steps once and spell Client-ID one way
The setup steps carried '1.' in their copy inside a numbered list, so
the card read '1. 1.'. The list numbers them now. Three integration
cards spelled 'Client ID' and 'Client Secret' where the others write
'Client-ID' and 'Client-Secret'.
2026-10-08 19:02:32 +02:00
Marc-André Bombeck b3fb9afa2c Lead the privacy and about pages with the reader's language
The privacy policy is German first in every body, but its section
headings, title and table of contents led in English and its date read
2026-08-11 on both lines. Headings now lead in German with the English
title beside them, the contents list follows, and each date line writes
the date as its language does. The about page's headings, date and
sign-in link follow the reader's locale. No legal wording changed.
2026-10-08 19:02:00 +02:00
Marc-André Bombeck 5bb6b6ad21 Name the share segment's not-found page in the URL-credential guard
It sits under the [token] segment, so the route-shape leg finds it; it
reads no token and resolves nothing.
2026-10-08 18:56:02 +02:00
Marc-André Bombeck f944c07cdf Explain a dead share link and a broken invite in the reader's terms
A practice opening an expired or revoked share link met the app's own
404, 'this page does not exist', with a button to a dashboard it has no
account for. The share segment now has its own not-found page: link
expired or invalid, ask the person who shared it for a new one, and no
link anywhere. It still answers 404 for every failure class alike.

A malformed invite link fell through to the plain registration form.
It now says the invitation is invalid and offers sign-in; the shape
check is public, so this reveals nothing about which tokens exist, and
a well-formed token still goes on to registration.
2026-10-08 18:55:45 +02:00
Marc-André Bombeck 005cb32ea8 Give every empty list one empty state and one add action
EmptyState now has a single action shape: centred at its own width on
the 44 px tap floor, never stretched edge to edge on some pages and
compact on others; the per-call-site size switch is gone, and the
dashed card takes the card radius. While a list is empty its empty
state carries the add action, so the header drops its own add button
there (measurements, mood, medications, labs, vaccinations, documents,
illness, check-ups, visits, procedures). The dashboard medication tile
of an account without medications offers 'Add a medication' instead of
'no data in the chosen range'.
2026-10-08 18:53:24 +02:00
Marc-André Bombeck 793c19e179 Keep delete affordances neutral until the confirmation
Row delete buttons, overflow-menu delete items and per-row revoke
buttons painted red in lists, so a list of tokens or shares read as a
wall of alarms. They now sit in the muted tone (foreground on hover)
or as an outline button; the red is kept for the confirmation dialog
that every one of them opens, and for the danger zones, which are a
destructive card-wide action by design.
2026-10-08 18:45:02 +02:00
Marc-André Bombeck 142dfa9360 Let the score section test settle its reveal
The section now waits for its reads before it shows; the mocked query
client reports nothing in flight.
2026-10-08 18:45:02 +02:00
Marc-André Bombeck aa3049a14c Emit the strain device block on every wellness read
The published WellnessScoreValue lists every field the engine sends, and
the contract test holds the two sets equal. The computed path now sends
device: null like anchor and components, and the device path has its
own contract case.
2026-10-08 18:45:01 +02:00
Marc-André Bombeck 651ec3aea8 Merge complete-window folding and the fold repair into release/v1.42.0 2026-10-08 18:42:46 +02:00
Marc-André Bombeck adc7baca14 Fold only complete local days and repair means folded from part of one
The daily-mean consolidation and the dense hourly fold cut at
`now - threshold` instead of a local day boundary. A day was folded in two
runs and the second run overwrote the stored mean with the mean of the
later part alone: every mean-type day, and the boundary hour of every
heart-rate, HRV and SpO2 day, plus the derived resting figure and the DAY
rollup of such a day. The Health Connect importer split its raw window at
the same unaligned instant.

- Fold up to the start of the account's local day the threshold ends in
  (`foldBoundary`), for every consolidation pass and the Health Connect
  importer's raw/hourly split.
- A window that already has a live `stats:` row is refolded from every
  sample of it, live rows plus the fold's own tombstones; with none left
  the stored mean stays.
- `folded_window` refuses a raw sample only when its local day lies before
  the aligned boundary and a live `stats:` row of its source covers it,
  also within reach of the sample after a timezone change.
- A deletion inside a day the fold had not finished is class B.
- Add a one-time, resumable fold repair per account that recomputes each
  hourly and daily mean from its remaining samples and writes only values
  that differ, recorded in `measurement_fold_repairs` (migration 0389).
- The compaction-tombstone purge waits for the repair per account, and the
  boot sends no purge run before one account is repaired.
- The restore keys live `stats:` ids by source, and writes compaction
  tombstones back from a file older than the account's repair, resetting
  the repair.
2026-10-08 18:42:01 +02:00
Marc-André Bombeck 2b91953cc9 Title the illness page as the navigation names it
The page called itself 'Krankheitstagebuch' while the navigation says
'Krankheit', and at 390 px the long word broke with a hyphen beside the
header actions. The title now reads the navigation label in every
locale.
2026-10-08 18:39:30 +02:00
Marc-André Bombeck d867c4bf83 Fold the medication API tab back into Erweitert
Seven tabs on a medication cut the strip off mid-label at 390 px. The
API tab's one group (per-medication tokens and drug coding) now sits in
Erweitert as a collapsed group before the danger zone, an old ?tab=api
link lands there, and on a phone the six remaining tabs wrap into two
rows instead of scrolling a label off the edge.
2026-10-08 18:39:12 +02:00
Marc-André Bombeck 6ca33882f1 Open the capture sheet from the dashboard's add button on phones
On the phone shell the header's 'Add' opened a dropdown while the plus
in the bottom nav, one thumb below, opened the capture sheet. The header
button now opens the same sheet there; the desktop rail keeps the
dropdown. The onboarding tour anchors on whichever of the two is laid
out.
2026-10-08 18:36:46 +02:00
Marc-André Bombeck b53782810f Keep the phone shell on a phone held sideways
A phone in landscape is 844 to 932 px wide, past md, so it got the
desktop rail and top bar: a sliver of content and no thumb-reachable
navigation. Two variants now decide the shell: shell-desktop from md up
except on a touch screen at most 520 px tall, shell-mobile otherwise.
The sidebar, top bar, bottom nav, the main column's bottom clearance,
the Coach button, the selection bar and the two full-height Coach pages
all key on them instead of a bare md.
2026-10-08 18:36:45 +02:00
Marc-André Bombeck a0c5ab6591 Hold data-shaped page bodies until their reads settle
Several pages stacked cards whose final height is the data's: the
illness day timeline, the measurement list, the insights arrange list,
the system status cards, the score notices that land with the slower
report. Each resolving on its own pushed the rest down the page, for a
CLS of 0.11 to 0.18 at desktop width. RevealWhenSettled mounts the body
at once but keeps it out of layout until no query is in flight (two
frames running, two seconds at most), with a placeholder in its place.

The chart loading shells were also ~100 px shorter than the card they
became: they now reserve the day-dot row, its caption and the collapsed
data-table disclosure, and the range-tab row at its 44 px height.
2026-10-08 18:34:11 +02:00
Marc-André Bombeck 1a52384a1d Open stat strips on the chart's window instead of all time
The strip painted the all-time summary until the chart reported its
visible window, then swapped to it, so every number jumped a beat after
load. The chart now reports only once its read has settled (a failed
read settles too), and until then the strip holds its cells at their
final height.
2026-10-08 18:20:57 +02:00
Marc-André Bombeck f7b6ea5dad Read Apple Health liveness off the data, not only the batch stamp
The status verdict looked only at the user's last-batch column, which
moves on a live batch alone. Rows that reached the account another way,
such as the export.zip import, left it null, and the card said 'waiting
for first data' beside a four-digit record count. The verdict now takes
the newest of that stamp, the per-type arrival ledger and the newest
stored sample.
2026-10-08 18:18:46 +02:00
Marc-André Bombeck ac482c508d Serve the device's day strain on the strain page when no proxy exists
The recovery page charts the band's own DAY_STRAIN while the strain
score page read only the computed proxy, so one account showed weeks of
strain on one page and 'not enough data' on the other. Without a proxy
row in the window the derived strain read now falls back to DAY_STRAIN:
the score is the reading as a share of the device scale, an additive
'device' block carries the native value, and the course below the card
charts that same series.
2026-10-08 18:17:20 +02:00
Marc-André Bombeck 605d9b1fb4 Stop rewarding a doubled or a skipped dose
The 'took it twice' and 'skipped a dose' badges rewarded over- and
under-dosing. Both definitions, their metrics and their copy in every
locale are gone. An unlock row already persisted for either id stays in
the database and in backups, but no longer renders: the evaluator only
walks the shipped catalogue.
2026-10-08 18:12:13 +02:00
Marc-André Bombeck d0e2238b65 Merge the app-wide UI audit into release/v1.42.0 2026-10-08 18:03:26 +02:00
Marc-André Bombeck 3a20f23f0f Give touch screens 44 px targets through the primitives
A pointer-coarse variant sits beside the pointer-fine one. Under it the
default, small and icon buttons, tab triggers, the sidebar rail rows and
the phone logo link reach the 44 px touch floor; a mouse keeps the
compact desktop density. The rail's height steps now apply to a mouse
only. A phone held sideways crosses the md breakpoint at about 390 px of
height, where the fixed footer left the list three rows: there the whole
rail scrolls as one column, without a native scrollbar.
2026-10-08 17:52:44 +02:00
Marc-André Bombeck 492c2a4131 Merge remote-tracking branch 'origin/release/v1.42.0' into v142/ui-audit 2026-10-08 17:44:00 +02:00
Marc-André Bombeck d286cf5bcd Merge PWA and mobile fixes into release/v1.42.0 2026-10-08 17:40:46 +02:00
Marc-André Bombeck 4673973a79 Title the mood sheet like the other capture sheets; fit the documents search hint
The mood page's sheet now reads 'Stimmung erfassen' like the same sheet
everywhere else, and the documents search placeholder is short enough to
show whole on a phone.
2026-10-08 17:34:21 +02:00
Marc-André Bombeck 105c293544 Size the catalog row skeleton to the row, drop the sparkline focus stop
The catalog's loading rows were 6 px shorter than the loaded ones, so the
groups below moved when a probe landed. The decorative score sparklines
sit in an aria-hidden wrapper, and Recharts' accessibility layer put a
focusable surface inside it.
2026-10-08 17:34:20 +02:00
Marc-André Bombeck 8a7eddaf26 Merge remote-tracking branch 'origin/release/v1.42.0' into v142/pwa 2026-10-08 17:24:38 +02:00
Marc-André Bombeck 2cd68779ec fix(pwa): follow the app's theme in the browser bar and toasts, lift toasts over the bottom bar
The theme-color tags followed the operating system's scheme, while the
app's theme is its own setting and defaults to dark: a phone in light
mode showed a light address bar or status bar over the dark app. The
client now rewrites both tags to the theme the page painted. The
toaster read Sonner's "system" for the same reason and now gets the
resolved theme as well.

Below 600 px Sonner stacks toasts 16 px above the bottom edge, which on
a phone is on top of the bottom bar: the "new version" hint covered the
tabs and the add button until dismissed. A mobile offset lifts toasts
above the bar and the home indicator.

pwa-mobile.spec.ts covers the hint's position and its Reload, a bottom
sheet standing on a stand-in keyboard, 16 px fields in portrait and held
sideways, and the sign-in card held sideways.
2026-10-08 17:24:30 +02:00
Marc-André Bombeck a4ad8ebe92 fix(mobile): fit the shell and sheets to safe areas and the on-screen keyboard
Safe areas. The installed app draws under the status bar and beside the
notch, and each 4rem header band padded the inset inside its own
border-box height: on a phone with a 59 px inset the top bar kept a 5 px
content box and the logo and account menu straddled its bottom border.
The authenticated shell now takes the top and side insets once on its
outer wrapper (`shell-safe-area`, the strip painted in the chrome
colour), so the top bar, the sidebar band and any docked panel start
below the status bar together and the seam between them holds. The two
Coach full-height pages subtract the inset at every width, the full-
height day sheet keeps its handle clear of the Dynamic Island, side
sheets keep clear of the status bar and the notch, the bottom bar and
the Coach button take the side insets of a phone held sideways, a
footer-less responsive sheet keeps its last row above the home
indicator, and the sign-in and onboarding shells pad themselves. The
sign-in shell grows with its content (`min-h-dvh`), so a phone held
sideways can scroll to the top of the card.

Keyboard. iOS Safari and Chrome on Android shrink only the visual
viewport when the keyboard opens, so a bottom sheet kept Save and the
Coach composer behind it. `KeyboardInsetBridge` publishes the covered
height (`--keyboard-inset`, `data-keyboard` on <html>); bottom sheets
stand on the keyboard and give up its height to their scrolling body,
and the bottom bar and the Coach button step aside while it is open.
Pinch-zoom and toolbar-sized changes do not count as a keyboard. The
sheet primitive names its side in `data-sheet-side`, which consumers do
not override the way they replace `data-slot`.

pwa-mobile-shell-guard.test.ts pins the structure no headless browser
can show (no browser in CI reports a safe-area inset).
2026-10-08 17:24:17 +02:00
Marc-André Bombeck 56a3008cf0 fix(mobile): keep form fields at 16 px on every touch screen
iOS and iPadOS Safari zoom the page into any focused field whose text
is below 16 px. The fields switched to 14 px at a width breakpoint
(`md:text-sm`, `sm:text-sm`), which a phone held sideways (844 px) and
every iPad pass, and the native select was 14 px at every width, so the
profile settings zoomed on a portrait phone too.

A `pointer-fine:` variant (a mouse or trackpad is the primary pointer)
now gates the compact size on Input, Textarea, NativeSelect, DateField,
TimeField and the Coach composer; touch screens keep 16 px at any width
and the desktop look is unchanged.
2026-10-08 17:23:57 +02:00
Marc-André Bombeck ac212cf6b7 fix(pwa): give the offline page a way back and reload once on any stale chunk
Without a connection a signed-in page opens the worker's offline page:
signed-in pages carry `private, no-store` from the proxy and are never
cached, so this is also what an offline launch of the installed app
shows. The page now offers a real 64 px retry control, reloads by itself
when the connection returns, follows the device's light or dark scheme
and keeps clear of the notch and home indicator. The worker's comment on
the page cache says plainly that only public pages land in it.

The single reload after a stale-shell chunk failure moves into
src/lib/pwa/chunk-reload.ts and now runs in the root error boundary too,
and it recognises a failed dynamic import as WebKit and Firefox word it.

pwa-offline.spec.ts runs in the service-worker project and proves the
offline strip, the offline page and the automatic recovery against the
real worker.
2026-10-08 17:23:47 +02:00
Marc-André Bombeck 503802e363 fix(pwa): update the worker on Reload instead of unregistering it
The "new version" Reload action unregistered every service worker before
reloading. Unregistering a registration ends its Web Push subscription
and nothing in the app re-creates one, so each update silently turned
push reminders off in the installed app: the server kept the dead
endpoint and the settings card showed push as off.

Reload now asks the registration to update (the new worker activates at
once and drops the previous release's caches), deletes only HealthLog's
own caches, and reloads. The cache-name contract moves into
sw-cache-policy.ts so the session-end cleanup and the poller share it.
2026-10-08 17:23:47 +02:00
Marc-André Bombeck fb3a13925d Merge remote-tracking branch 'origin/release/v1.42.0' into v142/ui-audit 2026-10-08 17:20:46 +02:00
Marc-André Bombeck ec272c9150 Show the callback path, not 'undefined/api/…', on an unset app URL
With NEXT_PUBLIC_APP_URL unset, some providers spliced the missing value
into their redirect template and the integration cards told the reader to
register 'undefined/api/polar/callback'. A callback that is not an
absolute http(s) URL now counts as not configured, so the card shows the
fixed path it shows for every other unconfigured provider.
2026-10-08 17:13:49 +02:00
Marc-André Bombeck 9f17960e7c Merge documents, wellness and mood polish into release/v1.42.0 2026-10-08 17:13:47 +02:00
Marc-André Bombeck 0f51a828c4 Keep chart ticks readable and insights strings in the reader's language
A word-length unit on the y-axis ticks ('Schritte', 'Atemzüge/min',
'mL/(kg·min)') was wider than the axis gutter and lost its leading
digits; such units stay off the ticks, where the stat strip above
already names them. The sleep stage toggle reads '7T / 14T / 30T' like
every other range control, the 'Mood entries' and '7-day' target sources
are named in the reader's language, and the BMI and medication empty
states ask the coach in that language too.
2026-10-08 17:10:41 +02:00
Marc-André Bombeck bde33b80c9 fix(insights): size the mood calendar skeleton to the height most calendars settle at
Without a known window the skeleton assumed twelve weeks, which on a
phone lands below the cell ceiling, so a short history's calendar grew
a few pixels when it replaced it. Eight weeks reaches the ceiling on
every width down to 360 px.
2026-10-08 17:06:04 +02:00
Marc-André Bombeck 3f00d2effd Merge remote-tracking branch 'origin/release/v1.42.0' into v142/ui-audit 2026-10-08 17:02:21 +02:00
Marc-André Bombeck 4d18c4e101 Stop insights sub-pages from shifting after first paint
Weight, blood pressure and pulse painted no stat strip until their read
landed, so the read strip and chart jumped down a card's height; the
strip now holds its place with the skeleton the HealthKit pages already
used, shared from the strip itself. The tab strip's scroll arrows render
from the start and only hide while the row fits, instead of mounting once
it measured as overflowing and shoving every pill sideways. The sleep
page shows the stage chart's skeleton while loading rather than a
'no stage data yet' card. Load shift on weight, blood pressure and pulse
drops from 0.3 to under 0.01.
2026-10-08 17:02:01 +02:00
Marc-André Bombeck 17b8b35397 Merge remote-tracking branch 'origin/release/v1.42.0' into v142/qol-1
# Conflicts:
#	src/app/insights/mood/page.tsx
#	src/components/charts/mood-heatmap.tsx
#	src/components/documents/document-bulk-bar.tsx
2026-10-08 16:49:03 +02:00
Marc-André Bombeck 41ed8eb6a3 feat(insights): mood calendar and chart arrive together; add via the header plus
On /insights/mood the line chart used to paint first and the calendar,
whose aggregate is the slower read, pushed it down when it landed
(CLS 0.16 at 390 px on a cold load). The page now holds both on
final-height skeletons until the chart's read has settled and the
calendar's has landed, then shows them in the same frame. The calendar's
height is written in container-query units, so its box is the right size
from the first paint, in the skeleton and the painted grid alike. A
server wrapper prefetches both reads in parallel (time-boxed, skipped
under a record switch, off when SSR prefetch is off), so on a warm cache
they are in the first HTML. The route and the prefetch share
`moodInsightsWire`, so the seeded cell is the route's shape.

The "Log a mood entry" and "Take a mental-wellbeing check-in" text links
under the heading are gone. Logging is the header plus, which opens the
mood form in place; the check-in keeps its own place in the navigation.
The plus is one shared component, `SubPageAddButton`, now also behind
the measurement pages' capture link and the workouts page, which drew
its own. An audit of every Insights page found no other text add-button
of this kind; the remaining buttons are empty-state calls to action.
A sweep keeps it that way.

`e2e/v142-qol.spec.ts` covers all three surfaces of this change set.
2026-10-08 16:45:49 +02:00
Marc-André Bombeck 1a5eab9f7c feat(insights): show every wellness score's course, drop the caveat line
Each score tile in "Your health scores" draws the score over its window
under the band word (the persisted window for recovery, stress and
strain; thirty days for the sleep score and readiness), on a fixed
0-100 scale so a few points do not read as a cliff. A score's detail page
carries its history directly under the score card: the stored nightly
scores get the full chart with the period switch, tooltip and day
access every measured page has; the two composites draw the course
their value carries.

The data was already there for three of the five. The sleep score now
returns the score of every scorable night in its window, judged by the
same blend and the same window baseline as the headline, so the last
point is the headline. Readiness reads its course from what the nightly
job persisted, the COMPUTED recovery row that is this exact blend,
without recomputing a single day. Both fields are additive.

The warning-coloured "descriptive proxy, not clinical" line above the
method is gone from all four score pages that carried it, with its keys
in all seven locales and the unused anatomy disclaimer key.
2026-10-08 16:45:24 +02:00
Marc-André Bombeck 4273af231e feat(documents): choose how the vault is laid out, and confirm every delete
The wrench beside Upload opens Settings, Appearance, Documents, the way
the illness journal offers its display options: preview cards or a
compact list, and months stacked as blocks or flowing across the full
width with the month name inline above the document that opens it. The
choice is stored per account in `users.documents_layout_json` behind
GET/PUT /api/documents/inbound/layout (preserve-when-absent, module
gated, the read delegable like the rest of the vault), carried in the
account-settings backup and cleared by the data wipe.

Deleting from the vault now always asks first. The bulk bar's Delete and
a focused card's Delete key both go through one confirmation on the page,
and the detail sheet's Delete opens its own; before, all three deleted on
the spot with only an undo toast behind them.

The selection bar is two tight lines: the count with an X at its right
edge, then one action row with Delete alone at the right in the
destructive style. Below 1024 px the verbs are icon-only at the 44 px tap
floor, so the row stays one line at 360 px. The bar carries the
selection-bar slot the Coach launcher already steps aside for.
2026-10-08 16:45:06 +02:00
Marc-André Bombeck ee8044b379 test(e2e): list the handover link among the signed-out routes 2026-10-08 16:43:30 +02:00
Marc-André Bombeck 06bd75b36e Merge consistent day access into release/v1.42.0 2026-10-08 16:38:10 +02:00
Marc-André Bombeck 6f8ea3faf2 chore: raise the aggregate bundle cap for the chart day doors
Measured 4386 KB gz on the release branch and 4446 with every chart drawn
in days opening its day; the difference is the aggregate counting the
shared hook, data table and day link once per chart chunk group. Every
eager route stays inside its allowance.
2026-10-08 16:35:16 +02:00
Marc-André Bombeck 3e1f630fa1 Fix copy, colour and frame details across the record pages
- Achievements: '1 Tag', not '1 Tage'; point totals grouped; whole
  descriptions instead of a clamp that hid the target at the end.
- Illness: the episode note label is translated, day notes read in the
  content colour.
- Coach history: conversation titles read in the content colour.
- Anamnesis and mental wellbeing: drop a second page gutter the shell
  already provides, and the nested main landmark.
- Privacy: repair words broken by a stray hyphen and space.
- Cycle: the ring caption reads 'Tag 9 deines Zyklus' as one phrase.
- Practitioners: the add button says 'Hinzufügen' like every other page;
  a shorter search placeholder fits a phone.
- Empty state cards inset their content from their border, so a phone's
  full-width action no longer runs into the frame.
2026-10-08 16:34:54 +02:00
Marc-André Bombeck 9fd43623dd Name the type on every mobile measurement row
On a phone the list showed an icon and a value only, so two readings in
the same unit (HRV and RMSSD, two weights) could not be told apart, and
only blood pressure carried a label. Each row now names its type above
the value. Score and rating tokens no longer print as units.
2026-10-08 16:34:53 +02:00
Marc-André Bombeck 75a0f00be9 Tidy the environment, invite and claim surfaces
Environment settings show the home date, travel ranges and latest air
quality day as formatted dates instead of ISO keys, the backfill card
ends in its action row with the status lines above it, and the
attribution list sits on the page's reading edge. The sharing invite card
keeps a one-sentence description and says once, under the field, that
the person needs an account here. An expired claim link offers the
sign-in page instead of a dead end.
2026-10-08 16:21:12 +02:00
Marc-André Bombeck 0d574f2f2d Hold the coach read strip's place while its read loads
The strip sits above the chart on every metric sub-page and painted
nothing until its read landed, so the chart and everything under it
jumped down a card's height after first paint. It now paints a card of
the same anatomy while loading.
2026-10-08 16:21:12 +02:00
Marc-André Bombeck 554f36aeab Show whole-number metrics as whole numbers
The dashboard tiles printed every value at one decimal: '121,0 mmHg',
'60,0 bpm', '9.240,0' steps. TrendCard takes the metric's precision, and
blood pressure, pulse, HRV, steps and mg/dL glucose pass zero. The
measurement list names count-shaped units the way the insights pages do
('13 Etagen', '12,5 Atemzüge/min') instead of the source's 'count'.
2026-10-08 16:21:12 +02:00
Marc-André Bombeck b80dc14142 Clear the accessibility findings on about, privacy, achievements, cycle and admin
Inline links on the prose pages carry an underline and their body copy
reads in the content colour, so the links no longer rely on colour alone
and the code chips meet contrast. Locked achievements drop the opacity
that pushed their text below AA. The cycle calendar nests its headers and
day cells in ARIA rows, the login overview's page-size select is named,
and the measurement type badges take a lighter wash in the light theme so
their label holds 4.5:1.
2026-10-08 16:21:00 +02:00
Marc-André Bombeck 03f374794e Settle the form sheets on one anatomy
Field labels no longer lose the space before '(optional)' or set the
colon apart from a unit, and a question label carries no colon after its
question mark. Cancel next to a primary is the outline button in every
form, the illness sheet's selects span the field width, and the reminder
sheet drops a description that repeated the page subtitle. On a phone a
sheet now opens with focus on itself rather than its first field, so a
date or name field no longer raises the keyboard over a sheet nobody has
read yet.
2026-10-08 16:20:42 +02:00
Marc-André Bombeck 0b881fe5b7 Name every capture sheet by what it logs, in one order
The measurement and mood sheets were titled with the bare 'Add', the
medication one with the noun alone. Every capture sheet now carries the
verb phrase the dashboard's add menu shows, and the add menu lists its
entries in the capture picker's order. Normalise 'z.B.' to 'z. B.' in the
German catalogue and drop the register page's subtitle, which repeated
its title.
2026-10-08 16:20:41 +02:00
Marc-André Bombeck 4e7221f59b fix: spell a day's sleep and scores as their charts do
The day route sends a night's length in min and a device score in score.
The day read the night as a bare 412 and the score in the wire's word;
it now reads a duration and the reader's word for points. The phone
sheet's header takes the short date, so a long weekday and month no
longer cut off the year at 360 px.
2026-10-08 16:20:36 +02:00
Marc-André Bombeck 7493ec48e4 Fit the sidebar rail to short windows and hide its native scrollbar
On a 13-inch laptop the rail's list overflowed and a classic scrollbar
with arrow buttons appeared inside it. The rows now step down by window
height (gaps close at 940 px, 36 px rows at 820 px, 32 px at 740 px), so
1366 x 768 fits every entry collapsed and expanded. Where the list still
scrolls, the native scrollbar is hidden, fades mark the edge that has
more, and a focused row scrolls clear of the fades.
2026-10-08 16:20:26 +02:00
Marc-André Bombeck 8bcb3e6301 fix: keep the two-step on chart points on touch
A point and a calendar cell are small targets; one tap that opened the
day opened days by accident. On a touch screen the first tap shows the
value again and the tooltip's calm 44 px row opens the day, on every
day-linked chart and calendar alike. A date in a list still opens in one
tap; a click on a fine pointer still opens directly. The tooltip keeps
not gliding into place.
2026-10-08 16:04:54 +02:00
Marc-André Bombeck fee6088e08 feat: open the day with one tap and from every place a day is shown
A tap on a point, a bar, a calendar cell or a date now opens the day at
once, as a click does; the tooltip's second step is gone and the tapped
value heads the day. The dashboard's charts (not its today area), the
adherence chart, the rhythm events, the ECG list and detail and the rest
mode notes open their days too.

The day's header carries the close control top left, the Coach panel's
own toggle and glyph, the date on one centred line and the arrows on the
right. While the day is docked, the Coach button and the documents bar
move clear of it.

Smooth the layer: the tooltip no longer glides into place on touch, the
neighbouring day is read before a step, the sheet animates in 300 ms and
not at all under reduced motion, and the opener stays put while the page
beside the docked day reflows.
2026-10-08 15:56:40 +02:00
Marc-André Bombeck c41ec02e92 feat: prepare the next visit from the server's notable window
The block under a planned visit read the day index and up to twelve days
to find what changed, so a dose change on a quiet day could fall out of
the picked days. It now reads GET /api/day/notable for the stretch from
the last visit to today: every change the server names, every notable
reading.

Add a guard that walks every chart, chart mount and calendar heatmap and
fails when one drawn in days lacks the day doors and is not on the
reasoned NOT_HERE list. Give the dose-strength curve, the efficacy chart,
the mood dimensions, the intraday pulse header and the environment note
the same doors.
2026-10-08 15:30:12 +02:00
Marc-André Bombeck 866e871102 Merge remote-tracking branch 'origin/release/v1.42.0' into v142/tk-consistency 2026-10-08 15:19:11 +02:00
Marc-André Bombeck 7fbde1bc83 feat: open the day from every chart drawn in days
Move the chart's doors to the day (click, the touch tooltip's button, the
dashed line through the open day, the row of day dots, the caption) into
one hook and give it to every chart with a date axis: recovery, sleep
duration and stages, the mood line and calendar, the intake calendar,
water and caffeine, lab markers, custom metrics, questionnaires and basal
temperature. Heatmaps share one adapter; every chart's keyboard way to a
day is its data table or the dated list beside it.
2026-10-08 15:19:09 +02:00
Marc-André Bombeck ce6d2fe72a Merge the day and timeline server into release/v1.42.0 2026-10-08 15:15:05 +02:00
Marc-André Bombeck b5596f872d ops: load pg_stat_statements in the bundled database 2026-10-08 15:01:02 +02:00
Marc-André Bombeck 43f2c639ae Merge remote-tracking branch 'origin/release/v1.42.0' into v142/t1-server
# Conflicts:
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/ko.json
#	messages/pl.json
2026-10-08 14:42:48 +02:00
Marc-André Bombeck a81dfaab3d perf(day): leave pulse to the resting heart rate in the extremes
A pulse day value is a mean over thousands of background samples, and
comparing it over two years scanned every one of them: a recent day on a
dense account took about 300 ms, two thirds of it here. Resting heart rate
stays among the compared vitals.
2026-10-08 14:40:37 +02:00
Marc-André Bombeck 5bd70c29e6 Merge the timeline into release/v1.42.0 2026-10-08 14:38:17 +02:00
Marc-André Bombeck dbd9618d44 Merge remote-tracking branch 'origin/release/v1.42.0' into v142/t1-server
# Conflicts:
#	docs/api/openapi.yaml
#	src/lib/ai/coach/stream-events.ts
#	src/lib/ai/coach/types.ts
2026-10-08 14:33:12 +02:00
Marc-André Bombeck 0924285cd6 feat: serve the day, its index, the timeline and life events
Replace the day, timeline and life-event stubs with their implementations
(#613) and drop the not-implemented helper, its error code and its OpenAPI
response with the last stub.

loadDay reads one local day for every reader: the readings live inside the
record's own day window (never the UTC rollups), through the canonical-source
engine the mood sheet already uses (resolveDayReadings in linked-context.ts),
with the person's usual range over the 30 days before; the record sections
from their own tables, each in the storage form it speaks (an instant in the
local window, a date string with its own zone, a calendar date). A switched-off
module's section is not read; a section the grant does not cover is named
not_shared and not read.

The day, the day index and the new visit preparation (GET /api/day/notable)
are admitted on the readings and narrowed per section through
actingDomainVisibility, so a scoped grant sees exactly its sections. The
timeline and its readiness stay whole-record reads and narrow their lanes the
same way.

Notable days are deterministic keys: a three-month high or low with history
behind it, a first reading, and in the visit preparation a regular reading
that stopped. The timeline lays out spans and points from the owning tables,
standing items without a date, and series by zoom: months from the rollup
tier inside its five-year window and from one cached live query before it.
The readiness inventory rates each lane with one link per gap.

Life events are created, edited and soft-deleted as MANAGE verbs with audit
rows, encrypted at rest, and join the sharing guard and the handler matrix.
get_day reads a day for the Coach and the MCP endpoint without life events,
notes or document names; its step carries the date it read.
2026-10-08 14:31:13 +02:00
Marc-André Bombeck 2b89e627ab Merge release/v1.42.0 with the day view into v142/t3-timeline
The timeline now opens a day through the shell's day layer (openDay) and reads ?day= with its parser instead of a copy of both; the capture picker's life-event form starts on the picker's defaultDate like the other forms.
2026-10-08 14:29:25 +02:00
Marc-André Bombeck 32258bc5ab feat: add the timeline page, its readiness inventory and life-event entry
The opt-in timeline module gets its page (#613). At 768 px and up the
record's lanes (life, conditions, allergies, medications, vaccinations,
doctor and procedures, labs, documents) are drawn in one SVG on a shared
time axis, with a year, month or week grid, open periods ending in an
arrow, a dashed lead-in where a start is unknown, dose marks and pauses on
the medication they belong to, a Today line and a selection line. Up to
three neutral value lines run below as monthly means, chosen in a menu.
Recharts stays for every value chart; this surface is plain SVG like the
heatmaps.

All geometry is pure (timeline-geometry.ts): window, scale, row packing
and label placement through one occupancy map, so no label overlaps
another label or a bar, none runs past the card edge, and labels that do
not fit are left out rather than squeezed. The width comes from a
ResizeObserver, so a docked day panel re-runs the layout.

The bar under the chart names the selected month's entries as chips, the
month's means and "Open 3 Jan.", which puts ?day= in the URL for the day
layer. The chart is one tab stop: arrows move the selection by a day, a
month or a year with the zoom, Home and End jump to the ends, Enter opens
the day. A visually hidden table lists every entry for screen readers.
The legend says once that stacked means at the same time, not cause and
effect.

Below 768 px the timeline is a chronicle, newest first by month or year,
with an Ongoing block for what has no meaningful start, rails beside the
rows an episode or course spans, and empty stretches named as such.

Switching the module on in Settings opens the readiness inventory: each
lane carries, is thin or is empty, with one link per gap and the verdict
as a count. The same inventory sits as a dismissible card on the timeline
while a gap is open, and under Layers as "Data coverage".

Life events are entered in a sheet (title, kind, a date to the day, month
or year, an optional end, an optional note) from the timeline, the
capture picker and the dashboard's add menu; one surface line maps the
capture kind to the timeline module, and it is offered in one's own
record only. Deleting asks first.

Server routes are built in parallel; the e2e journey answers them with the
contract's shapes. Copy in all seven locales; the timeline and lifeEvents
release-pending prefixes are gone.
2026-10-08 14:27:02 +02:00
Marc-André Bombeck 4cfad0e116 Merge the day view into release/v1.42.0 2026-10-08 14:21:00 +02:00
Marc-André Bombeck bf83d414e2 feat: open any past day as a layer over the page
A date is now a door to its day (#613). `?day=YYYY-MM-DD` opens the day
over whichever page is open: docked beside it from 1280 px, the way the
Coach's conversations panel sits, a sheet from the right below that, and a
bottom sheet on a phone that opens at a little over half the height and
grows to full height from its handle. On /coach it is always a sheet, so
there is never more than one docked panel. Back and Escape close it, the
previous and next day replace the history entry instead of adding one,
Alt with an arrow steps a day, focus moves to the day's heading on open and
returns to whatever opened it on close, and a polite region names the day.
A malformed or future date is dropped from the URL.

The day shows the value it was opened from with its comparison, what ran
through the day, the readings against the person's own usual range on a
short number line (eight curated, the rest behind "All values"), and what
happened in order of the clock, each row linking to its record. Loading,
failure, an empty day and areas a grant does not cover each read as such.
The footer offers the timeline when that module is on and capture for the
day, with the capture forms starting on that date.

Doors into it: a click on a daily point of a metric chart (a tap shows the
value and the tooltip offers the day on touch screens), the dates in the
chart's data table, a quiet row of dots under the axis for days with
entries and a ring for notable ones, the lab chart and lab readings, the
measurement, mood and intake lists, symptom and illness day headings,
episode onset and recovery, document dates, workout dates, the workout
detail, the mood and adherence heatmaps, and the days a Coach answer's
get_day calls read, shown as chips. The next planned visit carries what
happened since the last one, each date opening its day, with a doctor
report for that window.

Not here, and frozen by a guard: the dashboard's today area, mini charts
in tiles, the cycle calendar, model-written text, future dates, the
clinician share link and the doctor-report PDF.
2026-10-08 14:20:01 +02:00
Marc-André Bombeck b8f6372963 Merge the day and timeline contract into release/v1.42.0
# Conflicts:
#	src/__tests__/sharing-surface-guard.test.ts
2026-10-08 13:13:49 +02:00
Marc-André Bombeck 9d4a50282e Merge tickets part 2 into release/v1.42.0
# Conflicts:
#	src/__tests__/i18n-reverse-coverage.test.ts
2026-10-08 13:07:02 +02:00
Marc-André Bombeck c79fc6cc7b feat: lay down the day view and timeline contract
Land every file the day view, the timeline and life events share before
any of them is built, so the server, the day view and the timeline can be
built side by side without editing the same file (#613).

Schema and migration 0387: LifeEvent with a short category list, calendar
start and end dates, a day/month/year precision, encrypted title and note,
a soft-delete tombstone and an index for the per-user live list. Every new
column is annotated for the column-reader sweep. Life events travel in both
backup purposes with their two ends in one file, are wiped with the
account, and are registered for key rotation.

src/lib/day/contract.ts carries the constants and wire types for
GET /api/day/{date}, /api/day/index, /api/timeline, /api/timeline/readiness
and the life-event routes, free of Zod so the navigation chrome can import
it; the schemas beside it feed the routes and the published contract. The
Coach and MCP tool name get_day is reserved there, and life events are
listed as the one section no model ever sees.

The opt-in timeline module, its navigation entry, the day-section and
timeline-lane surfaces, the day and timeline query keys (evicted by every
record data bundle), and stub routes that answer 501 behind their
admissions. The four cross-section reads declare the whole record for now;
the life-event list is a profile read, and life events join the profile
sharing domain and its consent copy in all seven locales. The day,
timeline and lifeEvents message namespaces carry their base copy as
release-pending keys.

The aggregate bundle cap moves to 4400: the branch already measured 4365
against 4335, and the new copy adds 10 KB.
2026-10-08 13:03:37 +02:00
Marc-André Bombeck b299904552 Let a record define its own vaccines
A dose can now name a vaccine the catalogue does not list. The person
defines it once (a name, the diseases it protects against from the
catalogue's antigen list, the series length and the booster interval) and
logs doses against it; such a dose counts into each listed antigen's
series, settles a booster reminder keyed to one of them and offers a
booster, the way a catalogue combination does.

One resolver (resolve-vaccine-entry.ts) now answers for every consumer
that asked the catalogue directly: the series, the satisfy matcher, the
booster mint, the dose DTO, the doctor report, document links, the
upload suggestion, MCP search and the client's info sentences and
booster offer. The catalogue wins when a dose carries both.

The custom routes resolve the record through requireRecordAuth (list at
READ, create at WRITE with the actor-keyed write bucket, edit and remove
at MANAGE with the C4 overwrite audit) and join the sharing guard, the
MANAGE matrix and the activity feed. Removing a definition tombstones it
and lets go of its doses, each keeping a name. Definitions travel in the
backup and restore before the doses that name them; the dose form picks
them, adds one without leaving the form, and the page lists them for
editing. With the last contract stub implemented, the 501 helper and the
not_implemented code leave the tree.
2026-10-08 13:00:37 +02:00
Marc-André Bombeck 6ab72aec3e Fold Health Connect heart rate like Apple Health
The nightly dense-intraday fold scanned Apple Health rows only, so the
per-minute heart rate a Health Connect import writes for the last 90 days
never aged into hourly means and every new export added another quarter
of raw samples.

The fold now walks each fold source in turn, Apple Health first and
unchanged, and folds Health Connect samples into hourly stats: rows under
their own source, deleting the raw rows outright. The folded_window rule
covers both sources for the hour window and looks a covering row up under
the sample's own source; the day window stays Apple Health only. The
Health Connect importer applies the same rule to raw samples, so an
export that still holds folded minutes does not put them back.
2026-10-08 12:04:36 +02:00
Marc-André Bombeck 861ba21086 Merge tickets part 1 into release/v1.42.0
# Conflicts:
#	src/__tests__/i18n-reverse-coverage.test.ts
2026-10-08 11:35:07 +02:00
Marc-André Bombeck 7eb5659ab9 Merge iOS server asks and client work into release/v1.42.0
# Conflicts:
#	docs/api/openapi.yaml
#	src/lib/openapi/routes/auth.ts
2026-10-08 11:27:42 +02:00
Marc-André Bombeck 12e422c25e Lighten the client shell and finish four client surfaces
- Load each settings section on its own: the section route imported all
  twenty-one statically. A preload starts the chunk beside the record
  gate's read, and the loading state paints the settings card shell.
  /settings/[section] goes from 611 to 403 KB gz.
- Take Zod out of the shell every route shares: the account-access parse
  run on each boot is plain TypeScript now, held to the published schema
  by an equivalence test over good and broken blocks. The canonical unit
  table moves to its own module, so the display transform no longer
  pulls Zod in either. Layout entry 199 to 136 KB gz; /insights 369 to
  304, /medications 428 to 365, /labs 400 to 337.
- Open a saved Coach conversation at its last answer: an instant jump
  instead of the smooth scroll that lost the end, then follow late
  layout (charts, tables) for a few seconds until the reader scrolls.
- Show the document archive as a month grid of preview tiles, two
  across on a phone and three or four on a desktop, with the month
  headings kept.
- Add the admin card for the measurement table maintenance: a status
  read on the maintenance route, one calm confirmation, and the run's
  state while it is queued, running, finished or stopped.
- Re-base the bundle budget: /insights at 304, /settings/[section]
  watched at 403, aggregate cap 4335 with the reason written down.
2026-10-08 11:25:11 +02:00
Marc-André Bombeck 920bd19617 Merge managed profile handover into release/v1.42.0
# Conflicts:
#	docs/api/openapi.yaml
#	src/__tests__/i18n-reverse-coverage.test.ts
2026-10-08 11:21:25 +02:00
Marc-André Bombeck 8617f809f0 feat(sharing): hand a managed profile over to the person it describes
A guardian with a fresh second factor mints a one-time handover link
(hlp_ token, HMAC hash stored only, 1/7/14 days, at most one open per
profile, withdrawable) with a proposed access level per guardian: end,
view only or manage, view only by default. The link is shown once with a
copy button and a QR code; nothing is mailed. The panel sits at the end
of each managed-profile row and hints when the person is under 16.

The person opens /claim/<token>, sees a minimal preview (name, expiry,
who keeps which access, no health data) and chooses a username, a
required email and a password under the registration policy. The claim
is one transaction under the managed-profile lock: the token is
consumed, the credentials are set, the marker is cleared, every pending
invitation the record offered is withdrawn, each guardian's access is
set to its proposal, the disclaimer, onboarding and AI consent are
reset, and every step is audited on the record and on each guardian's
own account. A failure anywhere leaves the profile managed and the link
unused. Each guardian is notified on their own channels.

The new owner then sees each guardian's access on the first screen after
signing in and decides finally; until then the proposal holds, and the
decision waits at the top of Shared access. A guardian whose access
moved since the claim is left alone. The same screen points the owner
at their own notification channels, since reminders no longer reach the
guardians.

Both anonymous routes refuse a single-sign-on-only instance, refuse a
live session before spending the per-address rate limit, check the link
before probing names, and answer one 404 for every unusable token. The
token travels in request bodies only; hlp_ joins the redaction and
secret-shape rules and /claim/ the path-secret registry. Usernames
starting with managed- are reserved. The session-surface and acting-
account guards list the new surfaces with their reasons.
2026-10-08 11:18:14 +02:00
Marc-André Bombeck bb563b6506 Answer the native client's open server asks
- Publish timezone, glucoseUnit and a features block on the account
  payload; glucoseUnit reads as the closed pair the contract names, and
  features.trackIntake tells a separately released client that
  medications take the record-only switch.
- Refuse a new dose for a record-only medication with
  medication.intake.notTracked on every dose-writing route; the bulk
  route reports it per entry as intake_not_tracked, and correcting an
  already resolved event stays open.
- Answer passkey sign-in failures by name instead of a 500:
  passkey.challenge.expired, passkey.unknown, passkey.response.invalid
  and passkey.verification.failed, each with an audit row.
- Carry one account-wide adherence figure in meta.aggregate on the
  batched compliance read, weighted by expected doses, and accept
  ?days=30|60|90|180|365 for a report window.
- Accept ?days=30|90|180|365 on the mood insights read so stability
  covers the period shown, and name the window it covers.
- Keep only the reminder that announces a dose off APNs when the phone
  owns medication reminders; follow-ups for a dose still open are
  pushed again, marked by a followUp flag the reminder cron sets.
- Document which batch validation issues belong to an entry and pin it
  with a test that splits and resends a batch; publish rejectedFields
  items as ValidationIssue.
2026-10-08 10:32:11 +02:00
Marc-André Bombeck 7bcb6f71d9 Merge environment data into release/v1.42.0
# Conflicts:
#	src/__tests__/i18n-reverse-coverage.test.ts
2026-10-08 10:04:45 +02:00
Marc-André Bombeck 3c98d95a40 feat(environment): air quality, pollen and UV per day, locations sealed at rest
Fetch the Open-Meteo air-quality feed (CAMS, domains=auto) with the weather:
17 hourly variables folded into local days by fixed rules (means and maxima
only with 18 of 24 hours, ozone as the highest 8-hour running mean, UV only
with the midday hours), a value the feed did not cover stored as null, never
as zero. Requests are cut into 90-day chunks, newest first, and chunks before
mid 2022 ask for the eight pollutant and index variables only. An air-quality
failure never costs the weather row; the day stays unfetched and the nightly
gap fill (up to 90 days per account, newest first) asks for it again.

Every Open-Meteo request asks an instance-wide budget kept in Postgres first
(500 a minute, 4000 an hour, 8000 a day, weighted by the hosted pricing
rule). A refused request is not sent and the run ends as done with
budget_blocked. OPENMETEO_AIR_QUALITY_URL and ENVIRONMENT_AIR_QUALITY_DISABLED
are honoured; the account switch lives on PATCH /api/environment/preferences.

The home, each dated location period and each stored day keep their coarse
location sealed as one value under healthlog/environment-location/v1. The
writers write the sealed value only, the readers open it and fall back to the
readable columns for rows the free-text encryption backfill has not reached,
and the backfill now seals all three and clears the readable columns. The
backup carries the location readable in a portable file and sealed in a
disaster-recovery file; a restore seals either under the receiving key.

Three correlation channels join the environment family (PM2.5, the ozone
8-hour high, the day's highest pollen count), read through the averaged
same-day window and left empty while air quality is off. The Coach and MCP
gain get_environment: module-gated, clamped to the lookback limit, no
coordinate or place name anywhere, with provenance and the attribution
lines. The settings section gets the switch, the coverage and the newest
day's values; the dashboard shows a quiet chip on a day with high pollen,
a warm night or very poor air. Copernicus and Open-Meteo are credited next
to the values.
2026-10-08 10:02:39 +02:00
Marc-André Bombeck 321ed1241c Merge Health Connect import into release/v1.42.0
# Conflicts:
#	src/lib/jobs/apple-health-import-worker.ts
2026-10-08 09:59:05 +02:00
Marc-André Bombeck 996d2519d1 feat(import): read Health Connect exports
Upload the ZIP the Android Health Connect app writes and import its
`health_connect_export.db` in the background, the same way an Apple
Health export is imported: streamed to disk with a SHA-256, capped at
1 GiB, one import per account, a job row of kind `health_connect` that
the shared reconcile cron and staging sweep now look up on its own
queue.

The worker extracts the database (4 GiB ceiling, zip-bomb checks) and
opens it read-only with `immutable=1`, `trusted_schema=OFF` and
`query_only`. A view in place of a record table refuses the file,
columns are found by name, and schema versions below 9 are refused.
Records are read in batches of 500 and aggregated in SQLite: steps,
energy and distance as one total per day from the app the person's
Health Connect priority list ranks first, heart rate per minute for 90
days and as hourly means before that, sleep stages with the unknown
stage counted only in sessions without real stages, workouts, cycle
days, water and micronutrients. Apps whose integration the account has
connected are left out, and a reading already stored under another
source is skipped. Rows carry source HEALTH_CONNECT, `hc:<uuid>` or the
`stats:` ids, so a second import of the same export writes nothing.

Both staged files are removed on every way out; the job result holds
counts per type and per app only. Settings gain a Health Connect card
with the export steps, progress and the outcome, in all seven
languages, and the import is documented in
docs/integrations/health-connect.md.
2026-10-08 09:54:42 +02:00
Marc-André Bombeck 1fe4a7af95 fix: settle the new edit and HRV surfaces after a look at them
The HRV page's second chart reported its visible window into the same
stat-strip slot as the first, so the two replaced each other on every
render until React stopped it and the chart failed to load. Only the
primary chart reports the window now; the strip describes that measure.

The workout edit sheet no longer shows the "leave empty if you just
finished" hint, which is about logging and not about a stored start. On
the Apple Health card a type's name reads as the row's content above its
arrival line, with room between rows.
2026-10-08 09:52:51 +02:00
Marc-André Bombeck 196c331068 fix(mcp): make the metric and lab deep links land on their pages
MCP `search` and `fetch` link a metric as `/insights?metric=<id>` and a lab
value as `/labs?analyte=<name>`. Neither page read its query, so every link
opened the overview or the full list.

`/insights` now resolves the id server-side, in whichever spelling the
result carries (a Coach source key such as `hrv` or `bp`, a signal or
metric-status key such as `GRIP_STRENGTH`, a measurement type, or a
sub-page slug), and redirects to the metric's page, or to its value list
when no page focuses it. An id it cannot place renders the overview as
before.

`/labs` asks for the newest reading stored under the exact analyte name
and opens its marker's page; a name with no reading keeps the list and
drops the query.
2026-10-08 09:40:12 +02:00
Marc-André Bombeck b8c7eda89c test(insights): reset the HRV page mock without returning a teardown 2026-10-08 09:40:11 +02:00
Marc-André Bombeck 370270bbc1 feat(apple-health): ingest RMSSD heart-rate variability from iOS 27
iOS and watchOS 27 add `HKQuantityTypeIdentifierHeartRateVariabilityRMSSD`
(Apple's symbol `c:@HKQuantityTypeIdentifierHeartRateVariabilityRMSSD`,
available from 27.0). The live batch and the export.zip import now store it
as `HRV_RMSSD`, the type WHOOP, Oura and Polar already write, in
milliseconds, mean-aggregated and flagged privacy-sensitive like SDNN. The
accepted range is 1 to 300 ms.

RMSSD ranks on its own `hrvRmssd` ladder in the SQL source collapse, so a
day holding it from Apple Health and a strap resolves to one source
instead of a name tiebreak.

SDNN and RMSSD are different statistics and are kept apart once one
account holds both. The HRV page charts each on its own, titled with its
measure, instead of hiding RMSSD behind SDNN. The Coach's HRV section
carries one timeline per measure under `byMeasure` (same section key, so
every reader still finds it) rather than one daily series averaging the
two; the budget pass condenses and sheds each measure's timeline like a
glucose context.
2026-10-08 09:37:04 +02:00
Marc-André Bombeck 2731c46399 feat(workouts): edit a hand-entered workout from its detail page
A workout logged by hand could only be deleted and logged again. The
detail page now offers an edit control beside delete for a MANUAL workout
carrying the form's `manual:` id, in one's own record. It opens the log
sheet on the stored values and saves by re-posting that id through
`POST /api/workouts/batch`, which already applies such a re-post as an
overwrite of that one row. No new route.

The overwrite nulls every overwritable column a re-post leaves out, and
the form has no fields for heart rate, steps, elevation or pauses. The
edit therefore sends those back as stored. A field left as shown sends the
stored value rather than one re-parsed from its display, so saving an
untouched edit keeps the start's seconds and a distance shown in miles at
its exact metres, and answers `duplicate`.

The detail read's `avgHr` / `maxHr` can come from a same-session twin;
the read now also serves the row's own `storedAvgHr` / `storedMaxHr`,
which is what the edit sends back.
2026-10-08 09:28:56 +02:00
Marc-André Bombeck ba99551faa feat(apple-health): show when each type last arrived and how
The Apple Health card listed each type's newest sample. A type the phone
only hands over on "Sync all" can carry a reading from this morning and
never have arrived in the background, so the sample time could not answer
whether a type flows on its own.

The batch route's per-type ledger now records, in one upsert per batch,
when each Apple Health type last arrived, the trigger that carried it
(including the new `manual`), and when a batch last brought a new or
updated sample of it. Duplicate-only re-sends move the arrival but keep the
last new value; scoped credentials and MANUAL rows write nothing, the same
rule as the sync checkpoint. A ledger failure leaves a warn line and never
fails the ingest.

`GET /api/integrations/healthkit` lays `lastReceivedAt`, `lastTrigger` and
`lastNewSampleAt` over each `metricFreshness` entry (null for a type no live
sync has carried, absent on the workouts entry and when the ledger read
failed), and the card shows them as a second line per type.

The per-type newest-sample read walks live types through
`listLiveMeasurementTypes` and probes each type's newest row instead of
grouping every live row of the source.
2026-10-08 09:22:49 +02:00
Marc-André Bombeck c8470cc1e6 fix(jobs): log the maintenance job's caught failures and ship the provider-health strings 2026-10-08 09:09:04 +02:00
Marc-André Bombeck 5c23f9ed63 Merge AI operations and error-log discipline into release/v1.42.0 2026-10-08 09:06:58 +02:00
Marc-André Bombeck 511eae8241 fix(jobs): report every caught failure in background jobs or say why not
A catch that drops what it caught is where the silent nightly failures
started. Every `catch` and `.catch()` under src/lib/jobs now rethrows,
fails the job, writes to a ledger an operator reads, counts into the run's
failure facts, hands the error back to its caller, or logs at warn under a
stable action. The enqueue helpers that swallowed a failed send (briefing,
status, narrative, morning digest, document index / summary / thumbnail,
workout insight, boot-time sweeps), the Coach nudge's provider fallback,
the workout and reaction-line cleanups, the advisory-lock release, the
backup attempt bookkeeping and the cycle reminder's dedupe reads now leave
a warn line instead of nothing or an info annotation.

`error-level-discipline-guard` holds the rule. The catches that stay
silent are listed with their reason (the pgboss readers' documented null on
a web-only deployment, the key-ring trial, the restore drill's per-account
result, an already-deleted Telegram message); a new silent catch fails, an
entry that no longer matches fails, and each entry is shown to be
load-bearing by removing it.

A Coach turn cut short by its token, time or round budget writes
`coach.turn.budget_stop` at warn.
2026-10-08 09:06:17 +02:00
Marc-André Bombeck 27342ee734 fix(ai): name the real cause of failed briefings and stop retrying what cannot heal
The nightly briefing warm completed every night and read "all-failed",
because one run event held every account's annotations and the last
account won. Each candidate now runs in its own wide event and the run
reports every outcome through `reportJobRun`, so ok, dead sign-in,
withheld and failed accounts show side by side.

A dead Codex refresh token threw a bare error with no status, so the chain
read it as a network failure: the health ledger recorded hard_failed, no
"reconnect" hint appeared, and every night tried the same token. The
refresh now throws `CodexRefreshError` with a 401 for a dead grant
(invalid_grant / invalid_refresh_token on a 400 included), as do a missing
or undecryptable stored credential. The ledger records auth_failed, the
chain marks the primary credential expired, and the nightly pass reports
the account as auth_failed without queuing a retry.

A briefing the outbound screen withheld (a causal claim) failed outright,
and the 45-minute provider retry then asked the same question eight times
a night. The generator now makes one bounded repair that names the broken
rule and keeps the repaired payload only if it passes the same parse,
grounding and screen; a withheld outcome queues no retry.

A provider that keeps failing is paused: from five consecutive hard
failures the skip window starts at 30 minutes and doubles per further
failure, capped at a day. A paused provider is walked last and tried again
when the window lifts; one success resumes it. The operator card shows how
many accounts it is paused for and until when. After a 5xx from a
non-canonical OpenAI-compatible endpoint the client asks the free model
listing, and a configured model the endpoint no longer offers is named in
the error, on the hop line and on the card.

Also:
- The tiered context's live day band read `measured_at AT TIME ZONE tz`,
  which treats the stored UTC as local time; a far-from-UTC reader's
  evening readings landed on the next day. It converts from UTC first now.
- Google Health intraday heart rate re-reads two hours per sync instead of
  a day; daily summaries and sleep keep the day (#1023).
- The first filtered daily read that answers 400 and triggers the
  snake_case retry is recorded as a note, not an error (#1023).
- The single derived-score read goes through the derived cache with
  stale-while-revalidate, like the batch beside it.
2026-10-08 09:06:09 +02:00
Marc-André Bombeck 2210f823d6 feat(logging): give failed runs and caught failures their own error lines
A wide event carries one action and the last annotation wins, so a hop
that failed before the next provider answered, one failed account among
six, or a caught error the code carried on past was either a meta key on
someone else's info line or nothing at all. `emitSignal` writes one extra
line under a stable action and level, joined to the surrounding event by
request and trace id; `logCaught` / `caughtAs` are its warn-level shape
for a catch that degrades on purpose.

Every failed pg-boss run now writes `job.run.failed` at error with the
queue and how many runs of it in a row have failed, counted from pg-boss's
own rows (`readConsecutiveRunFailures`). `reportJobRun` takes a fan-out
pass's whole candidate list: an all-failed run is `job.run.failed`, a
partial one `job.run.partial` at warn, and the run reports `all_failed` in
its facts so a completed night with nothing done still counts toward the
streak. The repeated-failure alert keys on that one action and number.

Retention is per queue now. `failureReaderRetention` refuses a queue
retention under 96 hours (the 72-hour reader window plus a day), and a
guard refuses any `deleteAfterSeconds` that is neither that helper nor a
literal at or above the floor.

A refusal the request was meant to get (any error carrying a 4xx status)
is recorded at warn instead of error. Expired-token 401s were most of the
error lines and hid the real failures between them.
2026-10-08 09:05:54 +02:00
Marc-André Bombeck 2622f63571 Merge database and ingest work into release/v1.42.0 2026-10-08 08:48:55 +02:00
Marc-André Bombeck 900a5c4f1f perf: stop keeping folded samples and re-reading duplicates
The nightly folds soft-deleted every raw Apple Health sample they folded
into an hourly or daily stats row and kept it for 75 days. Nothing ever
read those rows as deletions; they only held a re-upload of the sample
off, and they were most of the measurements table and its indexes.

Ingest now refuses such a re-upload directly: a raw sample of a dense or
mean type, old enough to have been folded, whose hour or day a live
stats row covers, is a duplicate with reason folded_window. The batch
route and the Apple Health export import apply the same rule. With that
in place the dense retention and the daily-mean consolidation delete the
raw rows outright, and a new compaction-tombstone-purge job removes the
backlog: 5,000 rows a batch, one account at a time under the restore
lock, a short pause between batches, resumable because the predicate is
idempotent, queued at every boot and continuing itself while it makes
progress. A person's own deletions are never in its predicate. The sync
feed leaves compaction tombstones out and a restore does not write them
back; all three classify through the same function.

The batch route answers an exact re-sent sample without a transaction:
one read over the batch's sample ids, live or deleted, decides what the
reconciler would have decided, at the same point in the loop, so every
status, reason, counter and the cross-source merge stay as they were. A
batch of duplicates only no longer queues PR detection, a reminder sweep
or the detection audit row. Caches are evicted only for a foreground or
manual sync or a batch that wrote a hand-entered reading, and marked
stale otherwise; the targets cache follows the same split.

Also:
- POST /api/admin/maintenance/measurements queues VACUUM (ANALYZE) and a
  REINDEX INDEX CONCURRENTLY per index, largest first, on a connection of
  its own; it refuses while the purge runs, drops invalid leftover copies
  and stops at the first failed rebuild. Runbook in docs/ops.
- The pure volume queues keep their finished jobs for 96 hours instead
  of seven days, never less than the failure window plus a day.
- work_mem is set per app connection from DATABASE_WORK_MEM (16MB), also
  when the statement timeout is disabled.
- The PR-detection fallback tick re-scans only accounts whose readings
  or workouts changed since the previous tick.
- Type discovery for the status refill walks the live-rows index as a
  loose index scan instead of grouping every live row.
- The host metrics chart polls every five minutes, like its sampler.
- The batch route records per-type HealthKit arrivals.
2026-10-08 08:41:25 +02:00
Marc-André Bombeck aca497d9ac fix(insights): correlate residuals, not the calendar
Two daily series that both follow the season (temperature and blood
pressure, daylight and sleep) correlated strongly with nothing between
them. In a seeded simulation of such a pair the engine surfaced a
finding in 58 % of 400-day runs and 30 % of 180-day runs, and a real
next-day effect under the season came back with the wrong sign.

Every daily pair now goes through seasonallyAdjustedPearson: a linear
trend and one annual harmonic are removed from both series (trend only
under 120 paired days), the residuals are correlated, and the p-value
uses the Pyper-Peterman effective sample size. Shrinkage and the
phrasing tier read the effective size too. BH-FDR and the effect floor
are unchanged. Pairs too short to fit the basis are not tested.

The weather channels are tested once over the mean of the day before
and the day itself against the same day's outcome (lagDays 0), which
pairs the overnight low with the night it shaped now that sleep is
keyed on the wake day. Discovered pairs carry an additive nEff, the
same-day templates are wired, and the matrix skips the weather read
when the environment module is off.

scripts/compare-correlation-findings.ts counts findings per account
before and after, read-only, for the release-candidate dry run.
2026-10-08 08:32:15 +02:00
Marc-André Bombeck 70dc7dd24f ci: group weekly patch and minor dependency updates into one pull request per kind 2026-10-08 07:59:22 +02:00
Marc-André Bombeck 22c8fe2e8d ci: pin runners to ubuntu-24.04 ahead of the ubuntu-latest move 2026-10-08 07:58:54 +02:00
Marc-André Bombeck e63be67b5a feat: lay down the shared v1.42 contract
Land every file the v1.42 work shares before any of it is built, so the
parts built side by side never edit the same file.

Schema and migrations 0379-0386: the HEALTH_CONNECT measurement source,
ImportJob.kind, the air-quality columns and the per-user switch on the
environment tables, sealed environment locations with nullable plaintext,
managed-profile handover links, the per-type HealthKit arrival ledger, lab
unit-conversion provenance, and user-defined vaccines. Every new column is
annotated for the column-reader sweep.

HEALTH_CONNECT reaches every exhaustive list: the source labels, the
read enum (not the write allowlist), the source-priority ladder cap, now
the number of sources rather than a fixed eight, and the write-path smoke
test. RMSSD gets its own priority class and a 300 ms ceiling. The batch
syncTrigger gains `manual`, with its label on the Apple Health card.

Queues for the Health Connect import, the compaction-tombstone purge and
the measurement-table maintenance are registered with stub handlers;
host-metric sampling moves to every five minutes. Environment and work_mem
variables reach the compose whitelist, the production example and the
manifest. The proxy admits the handover link and redirects it at the edge,
and the Health Connect upload bypasses the proxy body limit.

The new routes answer 501 behind their final authentication and are
published in the contract; backup, wipe and key rotation classify the new
models and columns; the seven message bundles carry the base copy. Guards
that list release-pending keys and columns fail once an entry is wired, so
those lists empty themselves before the tag.

Also re-anchor a login-throttle fixture to the wall clock, where its
expiry is compared, and re-base the aggregate bundle cap on a measured
12 KB of new locale strings.
2026-10-08 07:56:59 +02:00
BernhardBuckel 503f0f183a feat(labs): add the lab unit conversion registry and its pure functions
Phase 2 of #1095: new code only, no behaviour change. Nothing imports it yet.

An analyte lists its units as affine maps to a hub unit (hub = a * value + b),
so any pair goes unit -> hub -> unit. Molar-mass factors are derived from the
formula and a cited atomic-weight table instead of being typed; glucose reuses
MGDL_PER_MMOL. HbA1c is the IFCC-NGSP master equation. Units that differ only
by a power of ten convert for any analyte, inside one family and never across
mass and amount. An entry with no source, a hub that is not one of its units, or
a non-positive factor makes the registry fail to load.

convertLabValue, convertLabRange and conversionsFor refuse rather than guess:
unknown unit, unknown analyte, a mass to amount pair without an analyte key,
a value that is not finite. Triglycerides, haemoglobin in mmol/L, ferritin and
B12 in pmol/L and enzymes in ukat/L stay refusals.

Refs #1095
2026-10-04 11:05:36 +02:00
2287 changed files with 230598 additions and 31124 deletions

No files matched your search

+36
View File
@@ -70,6 +70,12 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# for a self-hosted Nightscout that is not reachable from the public internet.
# NIGHTSCOUT_PRIVATE_ORIGINS=""
# Dawarich instances on your own network that the movement-context connection
# may reach, comma-separated exact origins (e.g. "http://dawarich.lan:3000").
# Each entry is one scheme://host[:port], never a suffix or a range. A public
# Dawarich instance needs nothing here.
# DAWARICH_PRIVATE_ORIGINS=""
# -----------------------------------------------------------------------------
# Notification targets on a private network -- optional
@@ -327,6 +333,13 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# docs/integrations/ai-providers.md.
# DOCUMENT_AI_LIMIT_PER_HOUR=""
# Minimum hours between two regenerations of a status card after a sync
# (optional). A watch or an integration delivers readings all day, and each
# delivery would otherwise regenerate the cards it touches every hour. Hand
# entries keep a one-hour gap. Default 6, whole hours from 1 to 48. The value
# under Admin > Coach wins over this one. See docs/integrations/ai-providers.md.
# AI_SYNC_REGEN_GAP_HOURS=""
# Hourly upload ceiling for each document-upload token (optional). Applies
# only to uploads made with a token minted under Settings > API & Tokens >
# Document tokens: a Paperless workflow or the import script. Every upload
@@ -450,6 +463,19 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# for the heaviest legitimate read (the live-aggregate analytics fallback on a
# large account). Set "0" to disable it and restore unbounded behaviour.
# DATABASE_STATEMENT_TIMEOUT_MS="60000"
#
# Postgres `work_mem` applied to every app session, as a Postgres size string.
# Default 16MB. Large sorts and aggregates that exceed it spill to disk; each
# sort step of each pooled connection may use this much memory, so raise it
# with the pool size in mind.
# DATABASE_WORK_MEM="16MB"
#
# Both settings above reach Postgres as the libpq `options` startup
# parameter. PgBouncer refuses that parameter ("unsupported startup
# parameter: options") unless it is listed in its ignore_startup_parameters.
# Behind such a pooler, set this to 1, true or yes to send no session
# settings at all; the pooler's or the database's own defaults then apply.
# DATABASE_SESSION_OPTIONS_DISABLED="1"
# -----------------------------------------------------------------------------
@@ -592,6 +618,16 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# force the public-host guard, so an internal instance is not blocked).
# OPENMETEO_BASE_URL="https://archive-api.open-meteo.com"
# OPENMETEO_GEOCODING_URL="https://geocoding-api.open-meteo.com"
#
# Air quality, pollen and UV come from the Open-Meteo air-quality API, which
# serves the Copernicus Atmosphere Monitoring Service (CAMS) models. CAMS data
# is free to use with attribution, which the app shows next to the values.
# Like the URLs above this one is optional and can point at a self-hosted
# Open-Meteo instance. Set ENVIRONMENT_AIR_QUALITY_DISABLED to 1, true or yes
# to stop the air-quality requests for the whole instance; weather and
# daylight keep working, and no user can turn air quality back on.
# OPENMETEO_AIR_QUALITY_URL="https://air-quality-api.open-meteo.com"
# ENVIRONMENT_AIR_QUALITY_DISABLED="1"
# ── Native canvas renderer (document thumbnails, scanned-PDF rasterization) ──
# The bundled renderer's x64 build uses AVX2 CPU instructions. On x86-64 hosts
+19 -3
View File
@@ -15,7 +15,7 @@ body:
id: version
attributes:
label: HealthLog version
description: Output of `/api/version` or the tag of the GHCR image you're running.
description: Settings, About shows it, as does `/api/version`, or the tag of the GHCR image you're running.
placeholder: "1.4.19"
validations:
required: true
@@ -55,11 +55,27 @@ body:
3. See error
validations:
required: true
- type: input
id: error-id
attributes:
label: Error ID
description: If the failure showed an error ID (eight characters, e.g. `3f9a2c1b`), paste it here. The operator finds the event with it under Admin, App logs.
placeholder: "3f9a2c1b"
- type: textarea
id: support-info
attributes:
label: Support info / support report
description: |
Everyone: Settings, About, "Copy support info".
Operators: Admin, System status, "Support report", then Copy.
Both are redacted (no names, emails, values or tokens); read them before you paste.
See https://github.com/MBombeck/HealthLog/blob/main/docs/self-hosting/support.md
render: text
- type: textarea
id: logs
attributes:
label: Relevant logs / screenshots
description: Paste any relevant log output (redact secrets) or attach screenshots. Wide-event logs from `/admin/app-logs` are useful.
label: Screenshots / other logs
description: Attach screenshots, or paste other log output if you have it. Redact secrets and personal data first.
render: shell
- type: input
id: browser
+26
View File
@@ -10,6 +10,32 @@ updates:
applies-to: security-updates
update-types:
- "patch"
# One weekly pull request per dependency kind instead of a trickle of
# single bumps: patch and minor updates travel together and pass the
# same checks once. Majors stay single so each gets its own review.
production-weekly:
dependency-type: "production"
update-types:
- "patch"
- "minor"
development-weekly:
dependency-type: "development"
update-types:
- "patch"
- "minor"
ignore:
# Next 16.4.0 breaks URL state after history traversal: with a sheet
# opened through `?doc=`, Back leaves it open, and `[` / `]` sometimes
# do not move `?day=`. Reproduced in three end-to-end journeys (3 of 40
# repeated runs on 16.4.0, 0 of 80 on 16.3.8 with everything else
# equal). 16.3.8 carries every published security fix, so the line
# waits for a 16.4 patch; the two packages that move with it wait too.
- dependency-name: "next"
versions: ["16.4.0"]
- dependency-name: "eslint-config-next"
versions: ["16.4.0"]
- dependency-name: "@next/bundle-analyzer"
versions: ["16.4.0"]
- package-ecosystem: "github-actions"
directory: "/"
schedule:
+2 -2
View File
@@ -26,7 +26,7 @@ permissions:
jobs:
coach-eval:
name: Coach eval — deterministic floor (always) + live judge (opt-in)
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 15
# Map the opt-in secret to a job-level env so the live-judge steps can gate
@@ -41,7 +41,7 @@ jobs:
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
+3 -3
View File
@@ -36,7 +36,7 @@ concurrency:
jobs:
analyze:
name: Analyse javascript-typescript
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: read
@@ -60,7 +60,7 @@ jobs:
# minutes and can produce nothing actionable, because nobody edits
# it and the fix for anything found there is `pnpm prisma generate`.
- name: Initialise CodeQL
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
languages: javascript-typescript
build-mode: none
@@ -75,6 +75,6 @@ jobs:
- public
- name: Analyse
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
category: "/language:javascript-typescript"
+2 -2
View File
@@ -11,7 +11,7 @@ permissions:
jobs:
auto-merge:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
if: github.actor == 'dependabot[bot]'
permissions:
contents: write
@@ -22,7 +22,7 @@ jobs:
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: "pnpm"
+6 -6
View File
@@ -39,7 +39,7 @@ concurrency:
jobs:
release-policy:
name: Validate release policy
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
actions: read
@@ -121,7 +121,7 @@ jobs:
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
runner: ubuntu-24.04
cache_scope: amd64
- platform: linux/arm64
runner: ubuntu-24.04-arm
@@ -296,7 +296,7 @@ jobs:
- name: Upload digest artifact
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: digest-${{ matrix.cache_scope }}
path: /tmp/digests/*
@@ -313,7 +313,7 @@ jobs:
# the registry instead of on one cross-emulated runner.
merge:
name: Merge multi-arch manifest
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
if: github.event_name != 'pull_request'
needs: [build]
timeout-minutes: 10
@@ -334,7 +334,7 @@ jobs:
steps:
- name: Download digest artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
path: /tmp/digests
pattern: digest-*
@@ -449,7 +449,7 @@ jobs:
promote:
name: Promote verified image to Coolify
needs: [merge, verify-published-image]
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
+7 -7
View File
@@ -24,7 +24,7 @@ jobs:
# mobile route sweep landed, and a slow runner tipped them over; three
# shards with a 30-minute cap leave room for both.
shard:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 30
strategy:
fail-fast: false
@@ -62,7 +62,7 @@ jobs:
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
@@ -106,7 +106,7 @@ jobs:
- name: Upload Playwright HTML report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: playwright-report-shard-${{ matrix.shard }}
path: playwright-report/
@@ -117,7 +117,7 @@ jobs:
# job below. Only uploaded when the shard ran to completion, so a missing
# artifact there means a shard that did not finish, not an empty suite.
- name: Upload Playwright JSON report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: playwright-json-shard-${{ matrix.shard }}
path: playwright-json/results.json
@@ -128,7 +128,7 @@ jobs:
# that name and answers for both shards: it fails when any shard failed or
# was cancelled, so a half-green matrix cannot satisfy the gate.
e2e:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
needs: shard
if: always()
timeout-minutes: 5
@@ -146,10 +146,10 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: playwright-json-shard-*
path: playwright-json
+2 -2
View File
@@ -21,7 +21,7 @@ permissions:
jobs:
env-check:
name: pnpm check-env against .env.production.example
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Checkout
@@ -31,7 +31,7 @@ jobs:
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
+3 -3
View File
@@ -22,7 +22,7 @@ jobs:
# job timeout only needs to catch a container that never boots. Each shard
# boots its own Postgres testcontainer.
shard:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 40
strategy:
fail-fast: false
@@ -34,7 +34,7 @@ jobs:
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
@@ -67,7 +67,7 @@ jobs:
# carries that name and answers for every shard: it fails when any shard
# failed or was cancelled, so a half-green matrix cannot satisfy the gate.
integration:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
needs: shard
if: always()
timeout-minutes: 5
+2 -2
View File
@@ -12,7 +12,7 @@ permissions:
jobs:
knip:
name: Dead-code gate
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
@@ -20,7 +20,7 @@ jobs:
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
# future contributor reintroduces the pattern.
grep:
name: Forbidden marker grep
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+1 -1
View File
@@ -45,7 +45,7 @@ permissions:
jobs:
verify:
name: Pull and smoke-test exact published image
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 10
env:
IMAGE_REF: ${{ inputs.image_ref }}
+3 -3
View File
@@ -25,7 +25,7 @@ permissions: read-all
jobs:
analysis:
name: Score supply-chain practices
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
# Write the findings into the repository's code-scanning alerts.
@@ -47,14 +47,14 @@ jobs:
persist-credentials: false
- name: Run analysis
uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
uses: ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e # v2.5.0
with:
results_file: scorecard-results.sarif
results_format: sarif
publish_results: true
- name: Upload results
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
sarif_file: scorecard-results.sarif
category: scorecard
+6 -6
View File
@@ -17,7 +17,7 @@ permissions:
jobs:
quality:
name: Lint, Typecheck & Test
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
if: github.event_name != 'schedule'
steps:
@@ -25,7 +25,7 @@ jobs:
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: "pnpm"
@@ -100,7 +100,7 @@ jobs:
# published image daily.
dependency-audit:
name: Dependency Audit
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
# Runs on pull requests too, but advisory: an advisory published against a
# dependency the branch did not touch must not block the branch. The push
# run on main and the daily schedule stay blocking, and a tag is only ever
@@ -112,7 +112,7 @@ jobs:
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: "pnpm"
@@ -130,7 +130,7 @@ jobs:
secret-scan:
name: Secret Scanning
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
if: github.event_name != 'schedule'
steps:
@@ -145,7 +145,7 @@ jobs:
docker-security:
name: Container Security
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
+2 -2
View File
@@ -45,13 +45,13 @@ permissions:
jobs:
journey:
name: Sign in, write a reading, read it back
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
+4 -4
View File
@@ -30,7 +30,7 @@ concurrency:
jobs:
filesystem:
name: Lockfile (including dev dependencies)
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: read
@@ -88,14 +88,14 @@ jobs:
skip-dirs: src/generated
- name: Upload results
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
sarif_file: trivy-filesystem.sarif
category: trivy-filesystem
published-image:
name: Published image
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: read
@@ -145,7 +145,7 @@ jobs:
exit-code: "0"
- name: Upload results
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
sarif_file: trivy-published-image.sarif
category: trivy-published-image
+588
View File
@@ -1,5 +1,593 @@
# Changelog
## [1.43.2] — 2026-10-16
### Fixed
- **AI consent is only ever given by the person.** Opening the AI settings in
the web app, and the iOS app's launch sync, minted an `ai_full` consent
receipt without anyone agreeing, so background AI work could reach the
operator's shared key without consent. `POST /api/consent/ai` and
`POST /api/consent/ai/web` now write a receipt only with
`"confirm": true` and answer 400 `consent.ai.confirmRequired` otherwise.
Where AI is blocked only by missing consent, the web shows a consent card
that names the service and the data, with an explicit Agree button.
Existing receipts stay valid; the AI settings show since when consent is
given and how to withdraw it. `/api/auth/me` publishes `ai.consent`
(`status`, `since`, `recipients`).
## [1.43.1] — 2026-10-16
### Fixed
- **Lab values keep their precision** in the doctor report, the share view,
the day view, the scan review and the Labs page: a stored value keeps every
digit it has, a converted value keeps the precision of the printed one.
Reference ranges follow the same rule, and so do GLP-1 doses and the
measurement rows of the report (Refs #1228).
- **Every illness episode stays reachable.** An ended episode that belongs to
a condition now shows in both the card and the list view, flares nested two
levels deep included, and the illness page loads past the first fifty
episodes (Refs #1224).
## [1.43.0] — 2026-10-16
Records that link to each other, supplement composition as a nutrient source,
life circumstances with AUDIT-C, AI usage levels, marker merging, Dawarich,
single sign-on handover and an error id on every failure.
### Added
- **Relation links.** Five link tables (migration 0410): lab result and
document, visit and medication, visit and vaccination, medication and
document, medication and condition. `POST/DELETE /api/medications/{id}/links`
and `/api/labs/{id}/links`; visit and vaccination link routes take the new
target kinds. Six detail responses carry `related[]`, filtered by module and
sharing scope; a scan committed from a document links its lab results.
Backed up both ways, portable restores included.
- **Supplement composition** (migration 0404). `PUT
/api/medications/{id}/ingredients` (MANAGE-delegable), `POST
.../ingredients/extract` (label photo through the document AI, owner only,
proposal only), `GET /api/nutrients/catalog` with input units and EFSA upper
limits (with the form each covers). Taken doses write `SUPPLEMENT` nutrient
rows per local day; `/api/nutrients` and `/daily` split by source and judge
the upper limit. DHA and EPA join the catalogue.
- **Life circumstances** (migration 0405). Six sections with a valid-since
date and per-section AI release (`/api/anamnesis/life-circumstances`,
`ai-sections`, `confirm`); AUDIT-C (`/api/anamnesis/audit-c`) in validated
en/de/ko/pl wording with per-locale cut-offs and sources, English with
`validatedLocale: false` for es/fr/it. Read by the Coach (released sections
only), the doctor report and FHIR. Screening hints by account language.
- **AI usage levels** (migration 0402). Frugal, standard and full; admin
ceiling and default, user choice within (`ai.usage` on `/api/auth/me`, `PUT
/api/auth/me/ai-usage`). Sync gap by source, compact JSON in prompts, a
stable Coach prompt prefix. `AI_SYNC_REGEN_GAP_HOURS` sets the instance's
hours between card refreshes after a sync.
- **Marker merge and aliases** (migration 0403).
`POST /api/biomarkers/{id}/merge` with a `dryRun` preview (the web always
shows it first), alias editing, alias choice on scan review, catalogue shorthands; link pickers search a year or
a month.
- **Dawarich** (migration 0406). Daily distance, car and train km, places
count, time away from home and travel days; no places or coordinates
stored. Private hosts only through `DAWARICH_PRIVATE_ORIGINS`. Movement
block in the day view, two correlation channels, travel suggestions.
- **Managed profile handover through single sign-on**
(`POST /api/auth/claim/sso`), also under `OIDC_ONLY`.
- **Supportability.** `meta.requestId` on every error envelope and an error id
with copy button in the web; "Copy support info" under Settings, About; a
separate problems ring in
`/api/admin/app-logs`; an admin support report with accounts anonymised.
- **Server-resolved fields for clients.** Direction verdicts and trend
sentiment on the snapshot, summary, targets and series; chart reference
lines; personal-record benchmarks; mood patterns; cycle `absorbed` /
`restored`; hourly `stats:` rows for summed types and `syncTrigger:
"backfill"` with `lastBackfillAt` (migration 0408).
- **Pulse day mean v2** (migration 0407). Sparse hours weigh `min(1, n/3)`;
admin dry run, apply and rebuild.
### Changed
- zod 4.6.7. Request instants must carry seconds (`isoDateTime()`); a refusal
says so and is counted as `validation.datetime.no_seconds`. OpenAPI writes
nullable fields as type arrays.
- The legacy integration status fields are deprecated and go in v1.44; read
`health`.
- Below a default band, blood pressure, resting pulse and body fat read as
`hold`; steps and SpO2 above their band never read as `up-bad`. Targets
judge the seven-day mean, like the dashboard.
- Migration 0409 drops the plaintext location columns once the v1.42
backfill has sealed every row; recovery steps in
`docs/ops/migration-0409-plaintext-location.md`.
- Push notifications to guardians and in discreet mode open the app root.
- Zod left the first load of `/`, `/measurements`, `/insights/mood` and
`/settings/[section]`.
### Fixed
- A backup with a qualitative lab value (empty unit) restores.
- Free weekdays reach a prompt only with the work section released; a
concurrent about-me save no longer re-enables a released section.
- Hour buckets and the nightly drain cut days in the same timezone; both
starts of the repeated fall-back hour count.
- Dawarich and Nightscout responses are read with a size bound; a Dawarich
window edge no longer overwrites a complete day.
- Supplement nutrient rows are recomputed when the nutrients module returns
or the timezone changes.
## [1.42.4] — 2026-10-16
Reliability: one integration health model with a watchdog and a shared
card, a resumable Google Health backfill, no fixed list cut-offs.
### Added
- **Connection health.** Every integration status route (and
`/api/integrations/status`, `/api/integrations/healthkit`,
`/api/record-settings/integrations`) carries `health`: state
(`ok | limited | degraded | action_required`), reason, since, one action,
unavailable types, last success, last data and redacted diagnostics
(`integration_statuses.recent_calls`, migration 0400).
- **Watchdog.** Hourly and daily passes flag a continuous source that goes
silent (24 h; 48 h for Apple Health, Oura and WHOOP; never for scales, BP
cuffs or Strava) and pages the admin once per incident for action needed,
provider errors or silence, with one recovery note; provider-wide incidents
are aggregated; the first run records standing incidents without paging.
- **Integration cards.** One card for every connection and the managed
profile view, rendered from `health` only, with a three-dot menu and a
collapsed, copyable diagnostics section.
- **HealthKit arrivals.** Medication doses record arrivals as
`MEDICATION_DOSE`; every type carries `lastBackgroundAt` (migration 0401
turns `healthkit_type_syncs.type` into text).
- `GET /api/labs/overview`: one row per biomarker with its latest reading.
- The container logs pending migrations before `migrate deploy`.
### Fixed
- **Google Health backfill (#1220).** Progress is saved per page inside a
collection; slices of five minutes or 500 pages; server errors and
timeouts are retried; a long rate limit ends the slice and keeps the place;
a refused resume filter falls back to a client-side skip that keeps its
own place. Every admitted import runs in its own background event. The
hourly sync waits for a running slice and stays inside its expiry.
- **Fixed list limits (#1222).** Visit link pickers open on the record's date
with search and paging (`/api/labs` `q`; illness episodes `from`, `to`,
`offset`, `sortDir`, `q`; encounters `offset`); the lab list reads the
overview; side effects, GLP-1 medications, drug-level intakes and symptoms
page through all entries. A guard rejects new fixed client limits.
- **Extra Apple Health dose (#1221).** A dose on another local day than the
held take is stored unscheduled instead of overwriting it; a same-day
entry converges onto the slot; dose history lets the slot's own take claim
it first.
- **Sleep score.** A night's midpoint comes from its main session; Oura
`rest` and `deleted` periods are skipped and earlier imports retired.
- Recovery page empty state; aging test fixtures made relative with a guard.
### API
- `health` on integration status responses; `metricFreshness[]` gains
`MEDICATION_DOSE` and `lastBackgroundAt`; `POST /api/medications/intake/bulk`
accepts `syncTrigger`; GLP-1 timeline adds `medications[]`.
## [1.42.3] — 2026-10-15
Withings API plans, lab unit conversion, chronic conditions and rest mode,
Oura scopes.
### Added
- **Lab unit conversion (#1095).** A registry of convertible analytes with a
hub unit per analyte, derived from molar masses (glucose, cholesterol,
creatinine, urea and BUN, uric acid, calcium, bilirubin,
vitamin D, HbA1c in both directions, power-of-ten pairs). Markers carry
`analyteKey`, the catalog slug matched from name and unit and re-derived
whenever either changes. `POST /api/labs`, the OCR commit and the
document commit convert a reading into its marker's unit when the pair is
known (201 with `converted: true`) and keep the printed value, unit and
range text; unconvertible pairs still answer 422 `labs.unit.mismatch`,
now with a `reason`. Converted ranges are stated in the marker unit
everywhere (note, Coach, MCP, doctor report, FHIR); the printed text is
shown as printed, with its unit. Contributed by @BernhardBuckel.
### Fixed
- **Withings API plans (since 2026-10-12).** A request for a data type
outside the account's Withings API plan fails whole with status 277, so
every measure sync failed. Syncs now drop the documented advanced types
on a 277, bisect any remainder, remember unavailable types per connection
(`withings_connections.api_plan_state`, migration 0398) and re-probe
weekly. ECG and sleep-summary fields outside the plan are skipped
quietly. A 277 never counts as an auth failure; connections parked by it
are released by the fallback cron. The Withings card lists the types the
plan excludes (`planUnavailable`).
- **Oura scopes (#1204).** The authorize request adds `spo2` and
`heart_health`. A 401 on single collections next to successful ones is
recorded as a missing scope (`users.oura_scope_denied`, migration 0398),
counts as success and shows a reconnect hint (`scopeReconnect`) instead
of an error state.
- **Rest mode and chronic conditions (#1216).** Only active episodes that
are not `CHRONIC_ONGOING` trigger rest mode; Day N, the nudge pause, the
streak freeze and the banners follow. Chronic conditions stay context for
the Coach and for coincident vital deviations.
- **ntfy quick answers (#1203).** Every refusal on
`/api/notifications/action/*` is logged with a reason; a non-POST request
gets 405 with `Allow: POST`.
- **Lab form (#1214).** The draw date gets a full-width row.
- **Lab edits** keep the conversion record unless the value changes.
### API
- `LabResult`: `converted`, `sourceValue`, `sourceUnit`,
`printedReferenceText`; `sourceReferenceText` is null for a converted
reading. `Biomarker.analyteKey`.
- Integration status: Withings `planUnavailable`, Oura `scopeReconnect`.
## [1.42.2] — 2026-10-11
Actionable notifications, record search in the command palette, automatic
intake recording, and fixes for Oura, non-UTC databases, documents and
Google Health backfills.
### Added
- **Notification tap targets (#1203).** One map resolves a target path per
event type; ntfy carries it as `Click`, Telegram as an "Open in HealthLog"
button on public https (text link on plain http), web push as `url`,
webhook and Gotify as a URL field, email as a link line, APNs as `url`.
No target for guardian deliveries, discreet cycle mode or the silent sync
push. ntfy titles and action labels outside ASCII are sent RFC 2047
encoded.
- **Notification content preference.** `notificationPrefs.content.detail`
(`generic` default, `detailed`) on `PATCH /api/auth/me/notification-prefs`;
with `detailed` the morning briefing title carries the score.
- **Mood quick answers on ntfy.** Three actions post to
`POST /api/notifications/action/[token]`: an HMAC token bound to user and
answer, valid six hours, single use per reminder, rate limited before
verification, recorded once in the idempotency table so a purged entry
cannot be answered again. Mood entries record source `NOTIFICATION`. A
reminder tapped while acting in another record opens the mood page
without the entry sheet. ntfy messages are published as JSON.
- **Record search (#1117).** `POST /api/search` (query in the body, so it
never appears in a URL or proxy log; read-level access, one search at a
time per account, 20 a minute) searches visits and
procedures, conditions, documents, vaccinations, lab results, medications,
symptoms, workouts and notes, decrypting encrypted text in bounded batches
with a shared time budget, and reports `truncated` when a bound stopped a
source. Matching is case-, accent- and umlaut-spelling-blind and shared
with the MCP `search` tool. The palette lists hits under "In your records";
each hit deep-links to its entry. Migration 0397 adds a partial index on
live readings that carry a note.
- **Open in new tab (#1202)** for PDF and image originals in the document
vault.
- **Record doses automatically.** `Medication.autoMarkTaken` (with
`autoMarkTakenSince`, migration 0395): a job every 15 minutes records each
due dose as taken from the moment the option is switched on, through the
schedule engine, never over a dose taken, skipped or deleted by hand, with
the same stock and rollup follow-ups as a manual take. Intakes carry
`autoRecorded`; no reminders are sent for such a medication. List and
detail responses carry the resolved `remindersActive`
(false also once a course has ended). Rolling cadences step past slots the
job will not record, and the card shows the same next slot.
### Fixed
- **Oura token exchange (#1204).** Codes are exchanged at
`moi.ouraring.com/oauth/v2/ext/oauth-token`; a code issued by that server is
never sent to the legacy endpoint. Refresh falls back to
`api.ouraring.com/oauth/token` once. The callback validates `iss` (RFC 9207).
- **Non-UTC database time zone (#1200).** Every connection (Prisma, pg-boss,
lock and maintenance clients) runs with `TimeZone=UTC`, with a `SET`
fallback when session options are disabled; raw SQL reads the clock as
`NOW() AT TIME ZONE 'UTC'`; boot warns when the session zone is not UTC.
- **Google Health backfill (#1194).** A slice that saved progress enqueues
the next slice and ends without `job.run.failed`; a slice without saved
progress still fails, and the chain stops after collections + 2 slices.
- **Admin AI spend card** sums today's spend on each account's local day.
- **Daily AI budget** is keyed on the account's local day.
- **Day view:** Escape inside the date picker no longer closes the day.
### Changed
- Docked strips run the full height, the header search is a magnifier icon,
`g p` toggles the day panel.
- Without a zoom in the URL the timeline fits its window to the record: three
months, clamped to the first entry (at least 28 days) for a young record,
and everything when the last three months are empty. An empty window offers
"Show everything" when older entries exist.
- The explanatory line under day-linked charts is gone.
### API
- New: `POST /api/search`, `POST /api/notifications/action/{token}`.
- Medication: `autoMarkTaken`, `autoMarkTakenSince`, `remindersActive`;
intakes and sync intake upserts: `autoRecorded`.
- `notificationPrefs.content.detail` on the notification preferences.
- APNs payloads carry `url` on every visible push.
## [1.42.1] — 2026-10-10
Fixes for Google Health syncing and the measurement maintenance run.
### Fixed
- **Google Health parked after a reconnect (#1194).** A backfill walk used
one access token throughout; after Google's one-hour expiry the walk saw
401s and the connection was parked at `error_reauth` although the refresh
token was valid. Resources now take a token source that renews before
expiry and once more on a mid-walk 401; a 429 is waited out (Retry-After,
RetryInfo or backoff) and stays transient; parking follows only a refused
grant at the token endpoint, and an account parked by a data-call 401
recovers on its next run. One sync per account at a time: the hourly run
skips, a manual sync answers 409 `sync_in_progress`, backfill and sleep
repair wait. An interrupted backfill resumes from `backfill_progress`
(migration 0393).
- **Overlapping Google Health sleep segments (#1195).** A batch keeps one
reading per natural key (the longer segment), a remaining collision is
settled through `reconcileExternalMeasurement`, and overlapping sessions
no longer clear each other's rows. Night totals use the union of segments.
- **Measurement maintenance on Docker (#1192).** The maintenance connection
sets `max_parallel_maintenance_workers=0` and needs no `/dev/shm` segment;
the bundled `docker-compose.yml` sets `shm_size: 256mb` for the database.
- **Dashboard spacing.** The tile row sat flush against the top card; a
`display: contents` wrapper had swallowed the gap.
### API
- `POST /api/google-health/sync` may answer 409 `sync_in_progress`.
## [1.42.0] — 2026-10-09
A day view behind every dated value, an optional timeline with life events,
air quality and pollen per day, a Health Connect import, and the handover of
a managed profile to the person it describes. The measurement table drops
the samples the nightly fold used to keep, after a one-time repair of means
an earlier fold computed from part of a day.
### Added
- **Day view (#613).** `?day=YYYY-MM-DD` opens one local day over the
current page: docked from 1280 px beside a fixed strip at the right edge
that opens and closes it and keeps it, with the last day, across pages; a
side sheet below that and a bottom sheet on a phone. On `/coach` it sits
next to the conversation list, each with its own strip. It shows the value
it was opened from, what ran through the day (one entry per medication
with the day's dose, the cycle day and phase), eight curated readings
against the 30-day usual range (the rest behind "All values"), the day's
scores (health score, readiness, recovery, sleep score, strain) and the
day's entries in clock order. Readings and scores link to their pages, and
the date in the header opens a calendar with Today. Every chart drawn in days opens it (on touch the tooltip offers the
day first), as do data tables, the measurement, mood and intake lists,
heatmaps, lab readings, documents, workouts, symptom and illness days and
the days a Coach answer read. Back and Escape close it, Alt with an arrow
steps a day. A planned visit shows what changed since the last one from
`GET /api/day/notable`. Not offered: the dashboard's today area, tile
charts, the cycle calendar, model text, future dates, the clinician share
link and the doctor-report PDF.
- **Timeline module (#613).** Opt-in. One SVG time axis from 768 px with
lanes for life events, conditions, allergies, medications, vaccinations,
visits and procedures, labs and documents (one row per medication, dose
changes and pauses as segments of its bar), a year, month or week grid or
any range, and as many value lines as the record has types, as quarterly,
monthly, weekly or daily means by span (a short gap dashed, a longer one
left open, a mean from fewer than three readings drawn hollow, each point
with its own tooltip); a chronicle, newest first, on a phone. Switching the module on opens a readiness inventory that rates
each lane and links each gap; it stays available as Data coverage.
- **Life events.** Title, kind (family, home, work, loss, other), a start
at day, month or year precision, optional end and note; title and note
encrypted at rest (migration 0387). Owner only in this release: no share
level or MANAGE grant reads or writes them, and no model ever receives
them. They travel in both backup purposes and are wiped with the account.
- **Air quality, pollen and UV (#615).** The environment fetch adds the
Open-Meteo air-quality feed (CAMS, `domains=auto`): PM2.5, PM10, NO2,
SO2, CO, the ozone 8-hour high, European and US AQI, UV, dust, aerosol
depth and six pollen groups, folded into local days (means and maxima need
18 of 24 hours) and stored as null where the feed has no value
(migration 0381). Pollen exists only in the European domain. A nightly
gap fill covers up to 90 days per account. PM2.5, the ozone high and the
highest pollen join the correlation channels. An account switch under
Location & weather (on by default), attributions beside the values, and a
dashboard chip on a day with high pollen, a warm night or very poor air.
- **Air quality for the past.** A background job fills air quality, pollen
and UV for earlier local days with an entry: after the update, when the
home is set and when air quality is switched on. A day inside a location
period is fetched at that place, a day from the home's effective date on
at the home; a day before it with no period stays empty rather than
borrowing a place the person was not at. The source reaches back to 2013
in Europe and to August 2022 elsewhere, pollen from 2021 in Europe only;
older days stay empty. Nearby days share one
request of up to 90 days, a run holds itself to 300 of the account's 400
daily calls and half of each instance window, and a long history spreads
over several nights. Location & weather shows the progress (migration
0391).
- **`get_environment` and `get_day`** for the Coach and MCP.
`get_environment` is module-gated and carries no coordinate or place name;
`get_day` leaves out life events, notes and document names and honours
the person's Coach exclusions.
- **Health Connect import (#972).** Upload the ZIP Android's Health Connect
exports under Export & Import. It is read in the background (read-only
SQLite, schema version 9 or later, 1 GiB upload cap, one import per
account): daily totals from the top-priority app, per-minute heart rate
for 90 days and hourly means before that, sleep stages, workouts, cycle
days, water and nutrients, under the new source `HEALTH_CONNECT`
(migrations 0379, 0380). Apps already connected directly are skipped and a
second import of the same file writes nothing.
- **Managed profile handover (#959).** A guardian with a fresh second factor
creates a one-time link (1, 7 or 14 days, shown once with a QR code) and
proposes no access, view only or manage per guardian. The person opens
`/claim/<token>`, sets a username, email and password, and decides each
guardian's access after signing in. Pending invitations are withdrawn;
disclaimer, onboarding and AI consent reset. Refused on `OIDC_ONLY`
instances (migrations 0383, 0390).
- **Custom vaccines (#1005).** A record defines its own vaccine with the
antigens it covers, series length and booster interval; its doses count
into each antigen's series and settle boosters (migration 0386).
- **Workout editing (#1162).** A hand-entered workout opens its form from
the detail page and saves as an overwrite of the same row.
- **HRV RMSSD from Apple Health (#1110).** iOS 27's
`HKQuantityTypeIdentifierHeartRateVariabilityRMSSD` is stored as
`HRV_RMSSD` (1 to 300 ms) with its own source-priority ladder, charted
apart from SDNN.
- **Apple Health arrival per type (#1173).** The Apple Health card shows
when each type last arrived, by which trigger (including the new
`manual` for Sync all) and when it last brought a new value
(migration 0384).
- **Documents layout.** Preview cards or a compact list, months flowing
side by side (the default) or stacked, per account (migration 0388). Only
a field the person picked is stored, so an untouched one follows the
default.
- **Measurement maintenance.** An admin card and
`POST /api/admin/maintenance/measurements` run `VACUUM (ANALYZE)` and a
`REINDEX INDEX CONCURRENTLY` per index, largest first.
- **Query statistics.** The bundled database loads `pg_stat_statements`;
`CREATE EXTENSION` once to read it (`docs/ops/query-statistics.md`).
- **Score history.** `/insights/health-score` charts the daily health score
with its usual band, broken where the score's recipe changed, from
`GET /api/insights/score-history`; readiness, recovery, sleep score,
stress and strain share one 7, 30, 90 days or all range picker.
- **Top card switch.** The dashboard layout settings can hide the top card
(`todayCardVisible`).
- **Keyboard shortcuts.** `g` then `d`, `m`, `l`, `t`, `i`, `c` or `s` opens
a page, `n` the add menu, `[` and `]` step the open day, and `?` (or the
account menu) lists them; ignored while typing in a field.
- **Command palette.** Cmd+K (Ctrl+K) or the search field in the top bar
finds pages, Insights pages, every Settings card by its anchor, module
switches and actions (new entry, the Coach, a backup, today, the
shortcuts), filtered by modules, AI availability, role and a shared
record's grant; typo-tolerant, with recent places when empty.
### Changed
- **Correlations remove season and trend.** Every daily pair is correlated
on residuals after a linear trend and one annual harmonic are taken out
of both series (trend only under 120 days), with p-values on the
Pyper-Peterman effective sample size. Weather channels compare the mean
of the day before and the day itself with the same day's outcome. Some
earlier findings no longer qualify.
- **Fold leftovers.** The nightly folds delete the raw samples they fold
instead of keeping them for 75 days. A re-sent raw sample whose hour or
day a live `stats:` row covers is a duplicate with reason `folded_window`,
in the batch route, the export import and the Health Connect import. A
background purge removes the backlog in batches of 5,000 per account; the
sync feed and a restore leave these deletions out. Health Connect heart
rate is folded like Apple Health's.
- **Duplicate batches.** A batch of samples the server already holds is
answered without a write and queues no record detection or reminder
sweep; caches are evicted only for a foreground or manual sync or a
hand-entered reading, and marked stale otherwise.
- **Locations encrypted.** The home location, travel periods and stored
environment days keep their coarse location sealed (migration 0382); a
background job seals existing rows.
- **Lighter client.** Settings sections load on their own (611 to 403 KB
gz) and the shared shell no longer carries Zod (199 to 136 KB gz).
- **Wellness scores** show their course on each tile and page; the sleep
score and readiness reads add `series`. The proxy caveat line is gone.
- **Empty states and add actions.** An empty list carries the one add
action; the Insights pages add through the header plus.
- **Touch and small screens.** 44 px targets on touch screens, a sidebar
that fits short windows without a native scrollbar, the phone shell on a
phone held sideways, 16 px fields, safe areas in the installed app and
sheets that stay above the on-screen keyboard.
- **Provider pause.** From five consecutive hard failures a provider is
skipped for a window that starts at 30 minutes and doubles, capped at a
day; a dead Codex refresh token reads as a sign-in to renew.
- **`work_mem`** is set per app connection from `DATABASE_WORK_MEM`
(16MB); `DATABASE_SESSION_OPTIONS_DISABLED` sends no session options for
hosts behind PgBouncer.
- Volume queues keep finished jobs for 96 hours instead of seven days.
### Fixed
- **Means folded from part of a day.** The folds cut at a fixed offset
instead of the local day boundary, so a second run replaced a mean-type
day, and the boundary hour of heart rate, HRV and SpO2, with the mean of
its later part. Folds now stop at the local day boundary, and a one-time
repair per account recomputes each mean from its samples and writes only
values that differ (migration 0389). It covers the last 75 days of daily
means and the 75 days before the 90-day raw window of hourly means, where
every sample of the day is still kept. Older days stay as they are: their
remaining samples can be a fragment of the day, and a mean over them would
be worse than the stored one. The nightly folds follow the same rule for a
day or hour that already has a mean. The purge waits for the repair.
- **Push after an update.** Reload on the new-version hint unregistered the
service worker, which ended the Web Push subscription; it now updates the
worker instead.
- **Discord and Slack webhooks.** A Discord webhook URL answered the test
and every reminder with "Cannot send an empty message", and a Slack
incoming webhook refused the body too: the generic format carried neither
Discord's `content` nor Slack's `text`. It now sends both as plain text
(title and message, cut to Discord's 2000 characters), tells Discord to
resolve no mentions, and keeps `title`, `message`, `eventType` and
`priority` unchanged for Home Assistant and n8n rules. Discreet mode
covers the new fields.
- **MCP deep links.** `/insights?metric=` and `/labs?analyte=` open the
metric's page and the marker's page.
- **Apple Health status** no longer reads "waiting for first data" after an
export import.
- **Strain** falls back to the device's day strain when no computed value
exists.
- Failed briefing runs name each account's outcome, a briefing held back
for a causal claim gets one repair attempt instead of hourly retries, and
the tiered context converts stored times to the reader's zone.
- Google Health re-reads two hours of intraday heart rate per sync, and the
first cycle of each local day a full day (#1023).
- The capture sheet opens from the dashboard's add button on phones; the
installed app follows its own theme in the status bar and toasts.
- `source-map-js` 1.2.2, `postcss-selector-parser` 7.1.6 and `smol-toml`
1.9.0 close CVE-2026-93749, CVE-2026-104844 and GHSA-r4xh-jqrq-34v2 in the
build and development toolchain.
### Removed
- The "took it twice" and "skipped a dose" achievements.
### API
All changes are additive.
- New routes: `/api/day/{date}`, `/api/day/index`, `/api/day/notable`,
`/api/timeline`, `/api/timeline/readiness`, `/api/life-events`,
`/api/vaccinations/custom`, `/api/environment/preferences`,
`/api/documents/inbound/layout`, `/api/import/health-connect-export`.
- The measurement source enum gains `HEALTH_CONNECT`.
- `/api/auth/me` adds `timezone`, `glucoseUnit` and `features.trackIntake`;
module availability lists `timeline`.
- `POST /api/measurements/batch` accepts `syncTrigger: "manual"`; a re-sent
sample whose hour or day is already condensed returns `duplicate` with
reason `folded_window`.
- `GET /api/integrations/healthkit` adds `lastReceivedAt`, `lastTrigger` and
`lastNewSampleAt` per type.
- The sync feed lists only deletions a person makes, not those of the
condensing step.
- `GET /api/environment` adds `airQuality` (with `history`, the progress of
the past-days backfill), `latestDay` and `attributions`.
- `GET`/`PUT /api/auth/me/source-priority` add `inUse`.
- A dose for a record-only medication is refused with 422
`medication.intake.notTracked`; failed passkey sign-ins answer with
`passkey.*` codes instead of a 500.
- `/api/medications/compliance` adds `meta.aggregate` and `?days=`;
`/api/mood/insights` takes `?days=` and returns `stabilityWindowDays`.
- Coach steps can name the tools `get_day` and `get_environment`, the
domains `day` and `environment`, and a `day` date.
- Vaccination doses carry `customVaccineId`, correlation findings `nEff`,
workouts `storedAvgHr` and `storedMaxHr`, and the sleep score and
readiness reads a `series` of past values.
- `/api/life-events*` answer only the record's owner; a delegate gets 403,
the day's `lifeEvents` section reads `not_shared` and the timeline has no
`life` lane.
- `POST /api/environment/backfill` answers 429
`environment.backfill_rate_limited` (three an hour per account) and 409
`environment.backfill_pending` while one is queued.
- `GET /api/environment` may return `lat` and `lon` as `null`.
- The handover claim preview no longer carries `grantId`; a guardian's
`displayName` may be `null`.
- The restore report adds the catalog `environmentLocationCiphertext`.
- `get_day` names documents and visits by kind only.
### Thanks
@lutzkind for #613, #615 and #586, @CritLoren for #972 and #1005, @ckaotik
for #959, @BernhardBuckel for #1110, @mills1975 for #1173 and @NebuPookins
for #1162.
## [1.41.2] — 2026-10-07
More room for a Coach turn, prompt caching that holds across its rounds, and
+9 -3
View File
@@ -202,14 +202,20 @@ COPY --from=builder /app/src/generated ./src/generated
# `@node-rs/argon2` from the traced runtime tree. Scripts that import the
# application graph (`@/…`, the generated Prisma client) run from a source
# checkout instead. `src/__tests__/container-scripts-ship-guard.test.ts` holds
# the runbooks and this list in step. Root-owned on purpose: the app user can
# run them but not rewrite them.
# the runbooks and the entrypoint against this list. `pending-migrations.mjs`
# is the entrypoint's own: it logs the pending migration count before
# `migrate deploy`. Root-owned on purpose: the app user can run them but not
# rewrite them.
COPY --from=builder /app/scripts/reset-password.mjs ./scripts/reset-password.mjs
COPY --from=builder /app/scripts/pending-migrations.mjs ./scripts/pending-migrations.mjs
COPY --from=builder /app/src/lib/auth/argon2-params.mjs ./src/lib/auth/argon2-params.mjs
COPY --from=builder /app/scripts/repair-intake-anomalies.ts ./scripts/repair-intake-anomalies.ts
# Prove the reset CLI's imports resolve in this tree: with every import found
# it stops at its own usage line; a missing module throws before reaching it.
RUN node scripts/reset-password.mjs 2>&1 | grep -q "reset-password: usage:"
# The boot notice parses, and the `pg` it loads on a real boot resolves here.
RUN env -u DATABASE_URL node scripts/pending-migrations.mjs && \
node --input-type=module -e "await import('pg')"
# Install the migration CLI, its config dependency, and a pinned launcher for
# the maintenance scripts already shipped in the standalone tree. The Prisma
@@ -247,7 +253,7 @@ RUN mkdir -p /opt/prisma-cli && \
npm pkg set 'overrides.mysql2=^3.22.0' && \
npm pkg set 'overrides.@hono/node-server=^1.19.15' && \
npm pkg set 'overrides.valibot=^1.4.2' && \
npm install --omit=dev prisma@7.10.0 @prisma/engines@7.10.0 tsx@4.23.1 dotenv@17.4.2 && \
npm install --omit=dev prisma@7.10.0 @prisma/engines@7.10.0 tsx@4.23.1 dotenv@18.0.6 && \
ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx && \
ln -sfn /opt/prisma-cli/node_modules/dotenv /app/node_modules/dotenv && \
ln -sfn /opt/prisma-cli/node_modules/prisma /app/node_modules/prisma && \
+7 -6
View File
@@ -1,12 +1,13 @@
{
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
"routeBaselineKbGz": {
"/page": 429,
"/insights/page": 368,
"/measurements/page": 411,
"/insights/mood/page": 379
"/page": 355,
"/insights/page": 304,
"/measurements/page": 335,
"/insights/mood/page": 334,
"/settings/[section]/page": 307
},
"routeDriftAllowanceKbGz": 20,
"totalClientKbGz": 4155,
"totalClientKbGz": 4975,
"maxRechartsChunks": 1
}
+35
View File
@@ -72,6 +72,11 @@ x-healthlog-env: &healthlog_env
# (default 60000; 0 disables it and restores the unbounded behaviour).
# Raise it if a very large account's analytics fallback is being cut off.
DATABASE_STATEMENT_TIMEOUT_MS: "${DATABASE_STATEMENT_TIMEOUT_MS:-}"
# Postgres work_mem for every app session (default 16MB).
DATABASE_WORK_MEM: "${DATABASE_WORK_MEM:-}"
# 1 sends neither of the two settings above (they ride the libpq `options`
# startup parameter, which PgBouncer refuses by default).
DATABASE_SESSION_OPTIONS_DISABLED: "${DATABASE_SESSION_OPTIONS_DISABLED:-}"
WITHINGS_WEBHOOK_SECRET: "${WITHINGS_WEBHOOK_SECRET:-}"
# Overrides the derived `${NEXT_PUBLIC_APP_URL}/api/withings/callback`;
# set it when the URL registered with Withings differs from the app's own
@@ -225,6 +230,11 @@ x-healthlog-env: &healthlog_env
# validator should accept, comma-separated (e.g. "http://nightscout.lan").
# Without it a LAN/private Nightscout URL is refused as an SSRF target.
NIGHTSCOUT_PRIVATE_ORIGINS: "${NIGHTSCOUT_PRIVATE_ORIGINS:-}"
# Dawarich instances on your own network that the movement-context
# connection may reach, comma-separated exact origins (e.g.
# "http://dawarich.lan:3000"). Without it a LAN/private Dawarich URL is
# refused as an SSRF target; a public instance needs nothing here.
DAWARICH_PRIVATE_ORIGINS: "${DAWARICH_PRIVATE_ORIGINS:-}"
# Webhook (Gotify, Home Assistant, n8n) and ntfy targets on your own
# network, comma-separated exact origins (e.g. "https://gotify.example.com,
# http://ntfy.lan:8080"). Without it a target that resolves to a private
@@ -244,6 +254,11 @@ x-healthlog-env: &healthlog_env
# the coordinate lookups off a third party.
OPENMETEO_BASE_URL: "${OPENMETEO_BASE_URL:-}"
OPENMETEO_GEOCODING_URL: "${OPENMETEO_GEOCODING_URL:-}"
# Air quality, pollen and UV (CAMS via Open-Meteo). Same hosted default and
# self-host option as above; ENVIRONMENT_AIR_QUALITY_DISABLED=1 stops the
# air-quality requests for the whole instance.
OPENMETEO_AIR_QUALITY_URL: "${OPENMETEO_AIR_QUALITY_URL:-}"
ENVIRONMENT_AIR_QUALITY_DISABLED: "${ENVIRONMENT_AIR_QUALITY_DISABLED:-}"
# OIDC SSO login (self-hosted IdP: Authentik/Keycloak/Authelia/etc).
# All three of ISSUER_URL/CLIENT_ID/CLIENT_SECRET together enable the
# "Sign in with SSO" button; set none to leave it disabled. OIDC_ONLY
@@ -322,6 +337,11 @@ x-healthlog-env: &healthlog_env
# (the `environment:` block is a whitelist). See
# docs/integrations/ai-providers.md.
DOCUMENT_AI_LIMIT_PER_HOUR: "${DOCUMENT_AI_LIMIT_PER_HOUR:-}"
# Minimum hours between two status-card regenerations after a sync
# (optional; default 6, whole hours 1-48; hand entries keep one hour). The
# value under Admin > Coach wins. Listed here so an operator value in .env
# reaches the container. See docs/integrations/ai-providers.md.
AI_SYNC_REGEN_GAP_HOURS: "${AI_SYNC_REGEN_GAP_HOURS:-}"
# Hourly upload ceiling for each narrow document-upload token (a Paperless
# workflow, the import script; optional; default 120, clamped 1-1000).
# Uploads from the web app and the phone keep their own bucket. Listed here
@@ -533,6 +553,21 @@ services:
# (concrete pin, active watch) from being dropped.
image: postgres:16.15-alpine
container_name: healthlog-db
# Loads pg_stat_statements so slow queries can be found from measurements
# instead of guesses. Loading the library only collects statistics; the
# view appears after a one-time `CREATE EXTENSION pg_stat_statements;`
# (docs/ops/query-statistics.md). Changing this line restarts the
# database once on the next `compose up`.
command:
- postgres
- -c
- shared_preload_libraries=pg_stat_statements
- -c
- pg_stat_statements.track=top
# Docker gives a container 64 MB of /dev/shm by default. Postgres keeps
# the shared memory of parallel queries and parallel maintenance there,
# and a larger plan fails with "could not resize shared memory segment".
shm_size: 256mb
# cap_drop: [ALL] is deliberately NOT applied here (unlike `app`): the
# official Postgres entrypoint starts as root, chowns the data
# directory, and drops privileges to the `postgres` user itself, which
+3
View File
@@ -85,6 +85,9 @@ done
echo "HealthLog: Database is reachable."
# ── Run Prisma migrations ────────────────────────────────────
# Say how many migrations this image is about to apply, with a reminder to
# back up first. Reporting only: it never stops the boot.
node scripts/pending-migrations.mjs || true
echo "HealthLog: Running database migrations..."
NODE_PATH=/opt/prisma-cli/node_modules \
node /opt/prisma-cli/node_modules/prisma/build/index.js migrate deploy
+8 -1
View File
@@ -33,7 +33,14 @@ notes, and machine-readable specs that ship alongside the source:
three access levels, the eight sections a grant can be narrowed to,
managed profiles for somebody with no login and the guardians who look
after them, what revocation does and does not undo, and which trust
properties of the instance are unchanged by any of it.
properties of the instance are unchanged by any of it. And
[`self-hosting/support.md`](./self-hosting/support.md) — reporting a
problem: error ids, the support info every account can copy, the
operator's redacted support report, and finding an error in the admin log.
- [`using/`](./using/) — notes on using the app that sit close to the
code. [`using/search.md`](./using/search.md) covers the command palette's
record search: opening it, what it finds, how matching works, and its
limits on very large and shared records.
- [`migration/`](./migration/) — migration notes. Currently only
v1.3 to v1.4; later releases are covered by `CHANGELOG.md` and
<https://docs.healthlog.dev>.
+13828 -5298
View File
File diff suppressed because it is too large. Load diff
+25
View File
@@ -431,6 +431,31 @@ many documents against a generous provider budget; lower it on a
tight one. The 429 names the actual wait (the window is an hour) and
carries the reset instant in `X-RateLimit-Reset`.
`AI_SYNC_REGEN_GAP_HOURS` (default `6`, whole hours from 1 to 48) is
the minimum time between two regenerations of a status card after a sync
at the `standard` AI usage level. A watch or an integration delivers
readings all day, and each delivery moves today's figures, so without a
gap every touched card regenerated about once an hour. A reading entered
by hand keeps a one-hour gap, so it is still narrated the same day. The
value set under Admin > Coach (AI usage levels) wins over the environment.
Each account runs at one of three AI usage levels, chosen under
Settings > AI within the bounds the operator sets under Admin > Coach (the
highest level allowed and the default for accounts without a choice):
| | `frugal` | `standard` | `full` |
| ------------------------------- | ------------------ | -------------------- | -------------------- |
| Status cards after a sync | at most once a day | the gap above | hourly |
| Status cards after a hand entry | every 6 hours | hourly | hourly |
| Snapshot size (compact JSON) | 10 000 characters | 14 000 | 14 000 |
| Background reasoning | off | job default | job default |
| Daily briefing | short form | full | full |
| Automatic document reading | on request only | the account's switch | the account's switch |
| Supplement label photo | not offered | offered | offered |
The Coach is the same at every level. Settings shows an estimate of
tokens per day for each level, scaled from the account's last seven days.
`LABS_OCR_LIMIT_PER_HOUR` (default `6`, clamped to 1–1000) does the
same for lab-report scans. The photo or PDF scan and the browser-OCR
text scan share one bucket per user, and a scan that fails before the
+11
View File
@@ -120,6 +120,14 @@ The HK quantity types that map to HealthLog metric types live in
mapped set (for example workout routes) is counted as unsupported and
not stored.
Heart-rate variability comes in two measures, and they stay two types.
`HeartRateVariabilitySDNN` becomes `HEART_RATE_VARIABILITY`;
`HeartRateVariabilityRMSSD`, which iOS and watchOS 27 added, becomes
`HRV_RMSSD`, the same type WHOOP, Oura and Polar write. RMSSD readings
up to 300 ms are accepted. When both arrive, the HRV page charts each on
its own and the Coach reads them apart, because one is not a stand-in for
the other.
### ECG archive safety, privacy, and idempotency
ECG support is source-agnostic: if HealthKit includes a valid
@@ -284,6 +292,9 @@ Concrete consequences:
source tag — the lowest rank — is reserved for legacy CSV/JSON
imports that pre-date the Apple Health passthrough.
RMSSD HRV has its own ladder, **WHOOP ≻ OURA ≻ POLAR ≻ APPLE_HEALTH**,
separate from the SDNN one.
Override per-user via Settings → Sources (`/settings/sources`).
## Failure modes
+82
View File
@@ -0,0 +1,82 @@
# Dawarich integration
HealthLog can read daily movement context from a [Dawarich](https://dawarich.app)
instance: how far you moved, how much of it by car or by train, how many places
you visited, how long you were away from home, and whether a day was spent in
another country. It is part of the Environment module and runs only while that
module is on.
HealthLog never stores a coordinate, a track, a place name or a visit. It reads
the day summaries and keeps one row of numbers per day. The place list is read
live when you pick your home and is not saved.
## What is read
Once a night (03:20 Europe/Berlin) HealthLog fetches the last seven days, ending
yesterday. Connecting fetches the last 30 days. Each request carries your API
key as `Authorization: Bearer <key>`.
| Dawarich endpoint | What HealthLog keeps |
| ----------------------- | -------------------------------------------------------------------------------------- |
| `GET /api/v1/timeline` | total distance, distance by car (`driving`), distance by train, places visited |
| `GET /api/v1/visits` | minutes spent at your home place, to work out time away from home |
| `GET /api/v1/residency` | the country of each day, sealed at rest, and whether it differs from your home country |
| `GET /api/v1/places` | nothing; the list is shown once so you can pick your home |
| `GET /api/v1/users/me` | nothing; used to check the key |
Time away from home is the day's length minus the time a visit at your home
place covers. Without a home place, or on a day without a single visit, it is
left empty rather than shown as zero. Dawarich leaves out a mode that moved
less than half a kilometre; HealthLog then reads that mode as zero for the day.
Residency needs API access on a hosted Dawarich plan; a self-hosted instance
has it. Without it there are no travel days, and everything else still works.
Dawarich keys residency by UTC date, so on a day that crosses a border late in
the evening the country can be the next morning's.
## Connect
1. In Dawarich, open your account settings and copy the API key.
2. In HealthLog, turn on the Environment module, then open
**Settings → Integrations → Dawarich**.
3. Enter the address of the instance and the key, and connect.
4. Pick your home place from the list, and set your home country if you like.
Without one, the country you spent most days in over the last year counts.
Run Dawarich and HealthLog in the same time zone. Dawarich groups the
timeline by its own zone, HealthLog by yours; if they differ, a day can come
back without its distances.
## A Dawarich instance on your own network
A public instance works without any server setting. An instance on a private
network (a LAN address, a `.lan` name, a container on the same host) is refused
unless the operator lists its exact origin:
```env
DAWARICH_PRIVATE_ORIGINS="http://dawarich.lan:3000"
```
The grammar is the one `NIGHTSCOUT_PRIVATE_ORIGINS` uses
([Nightscout](./nightscout.md)): one `scheme://host[:port]` per entry, comma
separated, no path, no wildcard and no range. Listing a host does not open its
subdomains or another port. The unspecified address, link-local and the
cloud-metadata range can never be listed. Restart HealthLog after changing it.
The variable is on the `environment:` whitelist in `docker-compose.yml`.
## Where the values show up
- The day view has an **On the move** section with the day's figures.
- Time away from home and the distance travelled join the correlation scan as
two more factors.
- Runs of at least two travel days that no trip covers yet are offered as
trips under **Settings → Environment → Location periods**. Using one fills
in the dates; you pick the place.
- The Coach and the MCP `get_day` tool see the figures and whether it was a
travel day, never the country.
## Disconnect
Disconnecting stops the nightly fetch and keeps the stored days. **Disconnect
and delete days** removes them as well. The days are part of the backup; the
address and key are not, so a restored account connects again.
+110
View File
@@ -120,6 +120,116 @@ GOOGLE_HEALTH_REDIRECT_URI="https://your-instance.example.com/api/google-health/
The Google Cloud client id/secret are per-user (Settings), not env vars.
## Backfill, the hourly sync and Google's rate limit
Only one sync runs per account at a time. **Sync now** reports that a sync is
already in progress while the history backfill that follows a connect is
running, and shows its progress instead of starting a second one.
The backfill reads the account's whole history, and for a watch that records
heart rate every minute that is a thousand-odd pages per year. It runs in
slices: a slice reads for up to five minutes or 500 pages, writes what it
read, notes how far it got and lets go of the account. The next slice starts
a minute later where the last one stopped. Two kinds of note are kept: a data
type read to the end is not read again, and a data type read page by page
(heart rate, weight, body fat, blood glucose, core temperature) keeps the
time of the oldest reading it has written, so the next slice asks Google only
for what is older. A restart or an update in the middle loses at most the page
that was being read.
Asking for "what is older" uses an upper bound on the sample time, which
Google's filter grammar allows but which a given account may still refuse. If
Google answers that first resumed request with `400`, the slice reads the data
type again without the bound, passes over the readings newer than its note
without writing them, and carries on writing from there. The refusal is noted
per data type, so later slices read without the bound straight away. The pages
passed over are not counted against the slice's 500 pages, but they do run on
its five minutes, and passing over stops after three minutes at most, so a
slice never holds the account longer than a slice. Wherever the walk stops, on
a limit or on a rate limit, it keeps Google's page token for the page it read
last, and the next slice reads that one page again and goes on from there
instead of passing over everything earlier slices wrote: the pages read stay
in proportion to the history. If Google no longer takes the token, or the page
it returns no longer reaches the noted reading, the slice starts again from the
newest page and passes over as before. A `400` that names something else (the
page size, or the page token of a walk that did not start from a kept one) is
still a failure.
The hourly sync waits for the slice that is running (up to eight minutes)
instead of skipping the account, so recent data keeps arriving while an old
history is still coming in. The hourly job stays inside its own fifteen-minute
limit: the wait is cut short when the job's time runs low, the sync after it
gets only what is left and leaves the rest of its window to the next hour, and
an account the job reaches after its time is up waits for the next hour.
Google limits how many requests one account may make per minute. When a sync
reaches that limit Google answers `429`, and HealthLog waits and repeats the
request. If Google asks for a longer wait than a minute, the slice ends there
and the next one starts five minutes later. A server error (`500`, `502`,
`503`, `504`), a request that times out and a dropped connection are repeated
three times before they count as a failure. None of these mark the connection
as needing a reconnect. Access tokens last an hour, and a long backfill renews
its token as it goes.
A slice that moved nothing forward (no data type finished, no reading older
than before) is a real failure: the job fails with the reason, pg-boss retries
it a few times, and after that the next restart offers the backfill again.
Everything already written stays.
If an older version left your Google Health card asking for a reconnect after
a backfill, the first sync after updating checks the stored grant and resumes
on its own when the grant is still valid.
### Reading the backfill in the logs
Every slice writes one log line when it ends, with
`"background":{"task_name":"job.integration_backfill"}`. The parts worth
reading:
- `meta.kind` is `google-health-backfill`, `meta.slice` the slice number
(0 for the first), `meta.job_id` the pg-boss job.
- `external_calls` lists the requests the slice made to Google, one entry per
request: `method` names the data type and page (`fetchHeartRate(page=212)`),
`status` the HTTP status, `duration_ms` how long it took. A request that was
repeated carries a `note` (`rate_limited_retrying_in_ms=4000`,
`server_error_retrying_in_ms=2000`, `no_response_retrying_in_ms=2000`,
`token_rejected_retrying_with_fresh_token`,
`edge_filter_rejected_reading_without_it`); one that failed for good
carries an `error`. Successful requests are listed up to 60 per line and
everything up to 200; `external_calls_omitted` counts the rest.
- `meta["googleHealth.backfill.slice"]` sums the slice up:
- `pages`, `elapsedMs` and `imported`: what the slice read, how long it
took, how many readings it wrote. `skippedPages` is the part of `pages`
read only to pass over readings already written, on a data type whose
upper-bound filter Google refused.
- `stopReason`: why it ended early. `time_budget` and `page_budget` are the
normal end of a slice, `rate_limited` means Google would not take more
requests for now, `job_budget` that the job itself ran out of time.
`null` means the slice read everything it started.
- `collectionsDone` of `collectionsTotal`: data types read to the end so
far.
- `collections`: one entry per data type the slice touched, with its
`status` (`done`, `stopped` short on a budget or a rate limit,
`deferred` to the next slice, or `failed`), its `pages`, and for the
page-by-page types
`edgeFrom` and `edgeTo`, the oldest reading written before and after the
slice.
- `edges`: where every unfinished page-by-page data type stands now.
- `meta["googleHealth.backfill.edgeFilterUnsupported"]` appears on the slice
where Google refused the upper-bound filter, listing the data types noted
as refusing it.
- `action.name` is `google_health.backfill.continued` for a slice that made
progress and queued the next one, `google_health.backfill.complete` for the
one that finished the history, and `google_health.backfill.stuck` for one
that moved nothing. A stuck slice also fails the job with a message that
names each unfinished data type and its status, for example
`no progress in slice 4, stopped on rate_limited; fetchHeartRate:stopped`.
While a slice runs, the usual `job.progress` line for the
`integration-backfill-admission` queue reports
`google_health_backfill_slice`, `google_health_backfill_pages` and
`google_health_backfill_collections_done` every two minutes.
## The 7-day re-consent caveat
While the OAuth consent screen stays in **Testing** publishing mode, Google
+166
View File
@@ -0,0 +1,166 @@
# Health Connect import
HealthLog reads the export file of Android's Health Connect and folds
it into the same timeline as every other source. There is no Android
app and no live sync: you export from the phone, upload the file, and
a background job imports it. Rows from this path carry the source
`HEALTH_CONNECT`.
## What you need
- An Android phone with Health Connect and its export feature. The
export arrived with Android 15 and reaches Android 14 through a
system update; if you do not see it, update Health Connect first.
- A cloud storage app on the phone (Google Drive, Dropbox, Nextcloud
or any other app that offers itself as a storage location). Health
Connect refuses to save its export to the phone's own storage or the
Downloads folder, so the file always goes through a cloud first.
- A HealthLog account in a browser. The upload uses your normal
session; there is no token to create.
## 1. Export on the phone
1. Open Health Connect (in the system settings, or the Health Connect
app).
2. Go to **Manage data**, then **Export and import**, then
**Scheduled export** or **Export now**, depending on the version.
3. Pick the cloud storage app as the destination. You can choose how
often Health Connect exports again; HealthLog only ever reads the
file you upload.
The result is a ZIP file named `Health Connect.zip` with a single
entry, `health_connect_export.db`. That entry is a copy of the phone's
Health Connect database (a SQLite file). Health Connect does not
encrypt it, so treat the file like any other export of your health
data.
## 2. Upload to HealthLog
Download the ZIP from the cloud to the computer or phone you use
HealthLog on, open **Settings → Export & Import** and drop the file
onto the **Health Connect export** card.
The endpoint is `POST /api/import/health-connect-export`
(`multipart/form-data`, field `file`). It:
1. Allows three uploads per minute per account.
2. Refuses anything above **1 GiB**, once against the declared
`Content-Length` and again while the body streams to disk.
3. Streams the body to a temp file and hashes it with SHA-256 on the
way. Uploading the same bytes again returns the earlier job
(`idempotent: true`) instead of importing twice; a failed job never
blocks a retry.
4. Runs one import per account at a time, Apple Health and Health
Connect together. A second upload while one runs answers `409` and
the file is discarded.
5. Records the upload's size and hash in the audit log, and nothing
from inside the file.
`GET /api/import/health-connect-export/status` answers the account's
latest Health Connect job, or `null`. The card polls it while a job
runs.
## 3. What the import does
The worker extracts `health_connect_export.db` (at most 4 GiB, with
the same zip-bomb checks as the Apple Health importer) and opens it
read-only. The file is treated as untrusted input:
- It is opened with SQLite's `immutable` flag, so a database last
written in WAL mode opens without its `-wal` and `-shm` files, and
nothing is ever written next to it.
- Views, triggers and generated columns in the file cannot call
functions with side effects (`trusted_schema=OFF`), and a view in
place of one of the record tables refuses the whole file.
- Columns are looked up by name. Health Connect adds columns as its
schema version moves, so their order differs between phones.
Database versions below 9 are refused; versions above 27 (the newest
known when this was written) are read, with a warning on the job.
A table that lacks a column the importer needs is skipped and named
in the job's `warnings`.
### What becomes what
| Health Connect | HealthLog |
| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| Weight, lean body mass (grams) | `WEIGHT`, `LEAN_BODY_MASS` in kg |
| Body fat | `BODY_FAT` in % |
| Blood pressure | `BLOOD_PRESSURE_SYS` and `BLOOD_PRESSURE_DIA`, one pair per reading |
| Resting heart rate, heart-rate variability (RMSSD) | `RESTING_HEART_RATE`, `HRV_RMSSD` |
| Oxygen saturation, respiratory rate, body temperature, VO2 max | the matching types |
| Blood glucose (mmol/L) | `BLOOD_GLUCOSE` in mg/dL, fasting and after-meal context kept |
| Heart rate | per minute for the last 90 days; older history as hourly means with the hour's minimum and maximum |
| Steps, active calories, distance | one total per day |
| Sleep sessions and stages | one row per stage; light sleep becomes core sleep, both awake stages awake time; a session without stages counts as asleep |
| Exercise sessions | workouts, with the Health Connect exercise type kept on the workout |
| Menstruation flow and periods | cycle day logs (needs cycle tracking switched on) |
| Hydration and vitamins and minerals | daily nutrient totals (needs the Nutrients module) |
Left out on purpose: food energy and macronutrients, exercise routes,
planned workouts, Health Connect's access logs, the "out of bed" sleep
stage, and an "unknown" sleep stage inside a session that also has real
stages.
### When several apps write the same thing
The export holds every app's data side by side. Summing them would
count a step the phone and the watch both saw twice, so:
- A day total (steps, calories, distance, water, nutrients) comes from
one app per day: the first one your Health Connect app priority
list names for that category, otherwise the app with the largest
total.
- Heart rate comes from one app per hour, by the same rule.
- Of two overlapping sleep sessions or workouts from different apps,
the one from the higher-ranked app is kept.
- Two apps writing the same type at the same instant keep the
higher-ranked app's value.
### Apps HealthLog already connects to
If your account has the Withings, Fitbit, Google Health, Oura, Polar
or WHOOP integration connected, that app's records in the export are
left out: HealthLog already receives them directly, and importing them
again would put every reading in twice. As a further safety net, a
reading that matches one already stored under another source (same
type and value within two seconds) is left out too. The job result
lists how many records each app contributed and which apps were left
out.
### Importing again
Every row is keyed by the record's Health Connect ID (`hc:<uuid>`),
day totals and hourly means by their `stats:` ID. Importing the same
export again writes nothing; a newer export that overlaps an older one
adds only what is new. A value that changed on the phone is updated;
a value you deleted in HealthLog stays deleted.
Heart rate imported per minute ages the way Apple Health heart rate
does: once a minute is older than 90 days, the nightly pass folds its
hour into one hourly mean and removes the minutes. A later import of
an export that still holds those minutes leaves them out, so an hour
is never counted twice.
## Privacy
- The uploaded ZIP and the extracted database are deleted as soon as
the job ends, whether it succeeded or failed. A periodic sweep
removes anything a crash left behind.
- The job result holds counts only: per measurement type and per app.
No value, no timestamp and no text from the file (notes, titles)
is stored on the job.
## Operator notes
- The worker uses Node's built-in `node:sqlite` module. It needs no
flag on Node 22 and prints one `ExperimentalWarning` per process.
- Memory stays bounded: the database is read in batches of 500 rows
and aggregated inside SQLite, so what the import keeps alive does not
grow with the length of the history. Measured under a 792 MB heap,
one and three years of minute-by-minute heart rate peaked at about
160 MB of heap in total.
- Split web and worker containers need a shared staging directory,
exactly as for the Apple Health import.
- The status of a running job shows up under **Settings → Export &
Import**; a job interrupted by a restart is marked failed by the
same reconcile pass that watches Apple Health imports.
+55
View File
@@ -25,6 +25,56 @@ Activity data (steps, active energy, distance, floors climbed) lands
via the `user.activity` OAuth scope shipped in v1.4.25. Sleep stages
arrive via the sleep series endpoint.
## Withings API plans (October 2026)
On 12 October 2026 Withings split its public API into plans
([plans page](https://static.withings.com/content/api/plans/plans.html)).
The developer app a self-hoster registers for HealthLog sits on the free
**Start for Free** plan, which covers the core biomarkers: activity, sleep,
body and heart. The FDA/CE-cleared biomarkers and a few body-composition
extras now need the paid **Advanced Biomarkers** plan.
A request that names anything outside the plan is refused as a whole with
status 277 ("One of the data type requested in this API request is not
available in your API plan"). Before v1.42.3 HealthLog asked for every type
in one request, so from that date no Withings reading imported at all, and
the connection showed "Reauthorisation required" although reconnecting could
not help.
**Still synced on the free plan:** weight, fat mass and fat-free mass, muscle
mass, bone mass, body water, blood pressure, heart rate, a manual SpO2
reading, body temperature, steps, distance and calories, and the sleep
stages (duration, deep, light, REM, awake, wake-ups).
**Needs the paid Advanced Biomarkers plan** (per Withings'
[available health data](https://developer.withings.com/developer-guide/v3/data-api/all-available-health-data/)
table): pulse wave velocity (meastype 91), vascular age (155), visceral fat
(170), VO2 max (123), skin temperature (73), ECG recordings and AFib
verdicts, and the sleep score, overnight HRV and overnight breathing rate
from the sleep summary.
**What HealthLog does about it, automatically:**
- When Withings refuses a request with 277, HealthLog asks again without
the types Withings lists as advanced. If that is still refused, it
splits the remaining types until it has found the ones the plan
refuses, within a fixed number of requests. Everything the plan allows
imports as before.
- The refused types are remembered per connection, so later syncs skip
them without a refused request. Once a week HealthLog asks for the full
set again, so a plan upgrade is picked up on its own.
- ECG recordings are skipped quietly while the plan refuses them.
- A plan refusal is never counted as a sync failure and never asks you to
reconnect. Connections that an earlier version parked over it are
released on the next hourly sync, and that sync imports everything since
the last successful one.
- Settings → Integrations → Withings shows a short note naming the
readings your plan withholds, with a link to the Withings plans page.
Nothing needs to be configured. If you move your developer app to the
Advanced Biomarkers plan, the extra readings start arriving within a week,
or right away after reconnecting Withings once.
## 1. Register a Withings developer app
1. Sign in at <https://developer.withings.com/> and **create a new
@@ -167,6 +217,11 @@ fixes the mismatch by updating the URL Withings holds.
### Sync is stuck on "Reauthorisation required"
If this started on or after 12 October 2026 and your version is older than
v1.42.3, the cause is the API plan change, not your grant: see
[Withings API plans (October 2026)](#withings-api-plans-october-2026).
Updating is the fix; reconnecting is not needed.
Withings revokes refresh tokens on password change, app un-authorise,
or after extended inactivity. The `WithingsConnection` row flips to
`error_reauth` status and the scheduled sync no-ops until the user
+6
View File
@@ -13,6 +13,12 @@ Migrations land **before** the new app container takes traffic. The
canonical command sequence on a single-instance host:
```sh
# 0. Dump the database first. A migration is not undone by going back to
# the previous image; the dump is the way back. See "Updates" in
# docs/self-hosting/getting-started.md.
docker compose exec -T db pg_dump -U healthlog healthlog \
| gzip > healthlog-before-update-$(date +%F).sql.gz
# 1. Pull the new image. If you pin a version with HEALTHLOG_IMAGE_REF,
# set it to the new one first.
docker compose pull app
+165
View File
@@ -0,0 +1,165 @@
# Measurement table maintenance
Since v1.42 the nightly folds of Apple Health data delete the raw samples they
fold into hourly or daily rows, instead of keeping them as deleted rows for 75
days. On an instance that has synced Apple Health for a while, those leftovers
("compaction tombstones") were most of the `measurements` table and of its
indexes. Three pieces deal with them, in this order:
1. **The fold repair** runs by itself, once per account, before anything is
deleted. Up to v1.42 the folds could fold a day in two runs, and the
second run stored the mean of the later part of the day only. The repair
recomputes those hourly and daily means from all of the day's samples,
including the compaction tombstones, and corrects a stored mean only when
it differs. It does so only where every sample of the day is provably
still in the table: the last 75 days for daily means, and for hourly
means (folded 90 days late) the 75 days before that. Older days stay as
they are, because the deleted rows of their samples have passed the
75-day retention and what is left can be a fragment of the day. When it
has finished an account it records that in `measurement_fold_repairs`.
2. **The backlog purge** runs by itself, after the repair. Every worker boot queues one
`compaction-tombstone-purge` job. It deletes the leftovers 5,000 rows at a
time, one account at a time, pausing briefly between batches, and stops
after 40 batches; a run that stopped with work left queues the next one a
minute later. Rows a person deleted are never touched by it: they keep the
75-day retention of the nightly tombstone cleanup. The purge leaves an
account alone until the repair has finished it, and a boot queues no purge
run while the repair has finished no account.
3. **The table maintenance** is yours to start, after the purge. It runs
`VACUUM (ANALYZE)` on `measurements` and then rebuilds each of its indexes
with `REINDEX INDEX CONCURRENTLY`, largest first. The purge leaves the
indexes full of empty pages, and only a rebuild gives that space back.
## Repair before purge
The order matters because the purge deletes what the repair reads. Do not
delete compaction tombstones by hand, and do not insert rows into
`measurement_fold_repairs` to hurry the purge along: a mean the repair has not
seen keeps its wrong value for good once its tombstones are gone.
The repair reports every run on the wide event `job.measurement_fold_repair`
(`fold_repair_means_checked`, `fold_repair_means_corrected`,
`fold_repair_resting_corrected`, `fold_repair_skipped_beyond_horizon` for the
older windows it left as they are, `fold_repair_samples_absorbed` for live
samples it took into a checked mean, and `fold_repair_by_type` with the same
counts per type; counts only, never a value). A second run over an account
corrects nothing. It is finished when every account has a row:
```sql
SELECT count(*) AS accounts_left
FROM users u
LEFT JOIN measurement_fold_repairs r ON r.user_id = u.id
WHERE r.user_id IS NULL;
```
Each run works on one account, a day at a time, and stops on its time budget
with a follow-up that resumes at the next day; an account under restore is
tried again five minutes later. While accounts are left, purge runs report
them as `compaction_purge_awaiting_repair_accounts`.
A restore of a backup exported before the account's repair finished writes
the compaction tombstones back, removes the account's row and queues the
repair again; the purge follows once the repair is through.
## Is the purge finished?
The purge reports every run on the wide event `job.compaction_tombstone_purge`
(`compaction_purge_deleted`, `compaction_purge_drained`,
`compaction_purge_deferred_accounts`). It is finished when a run reports
`drained: true` and deletes nothing. From `psql`:
```sql
SELECT state, count(*)
FROM pgboss.job
WHERE name = 'compaction-tombstone-purge'
GROUP BY state;
```
No `created`, `retry` or `active` rows means nothing is queued or running. The
maintenance refuses to start while one is.
## Before you start
- **Pick a quiet window.** Reads and writes keep working during the rebuild,
but each rebuild reads the whole table twice and writes a full new copy of
the index.
- **Free disk.** A concurrent rebuild needs room for one more copy of the
index it is rebuilding, plus the WAL it writes. Check the largest index:
```sql
SELECT c.relname, pg_size_pretty(pg_relation_size(c.oid))
FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
WHERE i.indrelid = 'measurements'::regclass
ORDER BY pg_relation_size(c.oid) DESC
LIMIT 3;
```
Have at least twice the largest size free on the database volume.
- **Take a backup** (Settings, Data, Backups), as before any operation on the
whole table.
## Starting it
In the admin area, or with a signed-in admin session:
```sh
curl -X POST https://<your-instance>/api/admin/maintenance/measurements \
-H 'content-type: application/json' \
--cookie 'healthlog_session=<session>' \
-d '{"vacuum": true, "reindex": true}'
```
The answer is `202` with `{ "enqueued": true }`, or `enqueued: false` when a
run is already queued or running. A Bearer token is refused: the route is
cookie-only, like every admin route. Without a background worker the route
answers `503`.
Either step can be left out (`"reindex": false` runs only the vacuum).
## What it does, and what stops it
The run uses a connection of its own: no statement timeout (a rebuild of a
large index takes longer than the app's 60 seconds), `maintenance_work_mem`
of 128 MB for this session only, and a lock timeout of five minutes, so a
rebuild that cannot get its lock gives up instead of holding writers behind
it.
- It refuses to start while the purge is queued or running.
- Before the first rebuild it drops any invalid `<index>_ccnew` copy an
interrupted earlier run left behind.
- If a rebuild fails, most likely for lack of disk, it drops that rebuild's
half-built copy at once and stops. The remaining indexes keep working as
they are. Free some space and start it again; finished indexes are simply
rebuilt once more.
- One run at a time. It is never retried automatically.
## Reading the result
The run's wide event `job.measurement_maintenance` carries
`maintenance_outcome` (`completed`, `refused_purge_running`, `failed`), the
table and index sizes before and after, and `maintenance_steps`, one entry
per step with its target, size before and after, and duration. The same
sizes from `psql`:
```sql
SELECT pg_size_pretty(pg_total_relation_size('measurements')) AS total,
pg_size_pretty(pg_indexes_size('measurements')) AS indexes;
```
## "could not resize shared memory segment"
Up to v1.42.0 the run could stop at once with `could not resize shared memory
segment ... No space left on device`. Postgres ran the vacuum in parallel and
kept its working memory in `/dev/shm`, which Docker limits to 64 MB per
container unless `shm_size` is set. From v1.42.1 the maintenance connection
runs single-process and needs no shared segment, and the bundled
`docker-compose.yml` gives the database `shm_size: 256mb`. On v1.42.0, add
`shm_size: 256mb` to the `db` service and restart the database container.
## Not part of this
`VACUUM FULL` would shrink the table file as well, but it locks the table for
reads and writes while it runs. After the purge the remaining table is small
and the space inside it is reused by new rows, so it is not needed. If you
want it anyway, run it yourself in a maintenance window with the app stopped.
@@ -0,0 +1,98 @@
# Upgrade stopped at migration 0409
v1.43 removes the readable location columns that v1.42 replaced with sealed
copies (`users.home_lat/home_lon/home_label`,
`environment_contexts.lat/lon/location_label`,
`environment_travel_locations.lat/lon/label`). Before it drops anything,
migration `0409_drop_plaintext_location` checks that the v1.42 encryption
backfill has sealed every one of those values. If any row still holds a
readable location, the migration stops and the app container does not start.
## What you see
The container exits during the migration step with:
```text
A migration failed to apply. New migrations cannot be applied before the error is recovered from. ...
Migration name: 0409_drop_plaintext_location
Database error code: P0001
Database error:
ERROR: HealthLog migration 0409 stopped: 3 home location(s), 0 location period(s) and 12 daily location(s) are still stored in plaintext. ...
```
On every later start, Prisma refuses with `P3009` ("migrate found failed
migrations in the target database") until the failed attempt is cleared.
## What it means
Nothing was lost and nothing was dropped. The check runs before any other
statement in the migration, so the location columns and their values are all
still there. The migrations before 0409 in the same image did apply; they only
add tables, columns and enum values, so the previous release runs on top of
them unchanged.
The cause is a v1.42 instance that was not running long enough for its
encryption backfill to reach every account, for example a host that went from
v1.41 straight to v1.43, or one that was stopped soon after updating to v1.42.
## How to recover
The order matters: clear the failed attempt first, because the previous
release runs `prisma migrate deploy` on start as well and refuses to boot
while a failed migration is on record.
```bash
# 1. Mark the failed 0409 attempt as rolled back. The entrypoint is replaced
# so the container does not try to migrate first.
docker compose run --rm --entrypoint sh app -c \
'NODE_PATH=/opt/prisma-cli/node_modules node /opt/prisma-cli/node_modules/prisma/build/index.js migrate resolve --rolled-back 0409_drop_plaintext_location'
# 2. Start the previous release. Set this in the compose `.env` (or your
# platform's environment) and recreate the container.
# HEALTHLOG_IMAGE_REF=:v1.42.4
docker compose up -d app
```
The previous release starts the encryption backfill about three minutes after
boot and again once a day. It works through the accounts one at a time. Watch
it with the three counts the migration checks; all three must reach 0:
```bash
docker compose exec -T db psql -U healthlog healthlog -c "
SELECT
(SELECT count(*) FROM users WHERE home_lat IS NOT NULL) AS home,
(SELECT count(*) FROM environment_travel_locations WHERE lat IS NOT NULL) AS periods,
(SELECT count(*) FROM environment_contexts WHERE lat IS NOT NULL) AS days;"
```
The app log shows each pass as
`[free-text-encryption-backfill] user=... ` lines. If the counts stop going
down, look for `[free-text-encryption-backfill] user=... failed` in the log;
the error on the line after it names the cause.
Once all three counts are 0:
```bash
# 3. Dump the database. This is the copy you go back to if anything else
# goes wrong; keep it with your encryption key.
docker compose exec -T db pg_dump -U healthlog healthlog \
| gzip > healthlog-before-0409-$(date +%F).sql.gz
# 4. Upgrade again: remove the pin (or set it to the new version) and
# recreate the container. Migration 0409 now runs through.
docker compose pull app
docker compose up -d app
curl -s http://localhost:3000/api/version
```
## What not to do
- Do not clear the readable columns by hand to get past the check. The
check exists because those values have no sealed copy yet; setting them to
`NULL` deletes the location for good.
- Do not run `migrate resolve --applied 0409_drop_plaintext_location`. That
records the migration as done while the columns are still there, and the
v1.43 schema no longer matches the database.
Background: [`deploy.md`](deploy.md) for the general update and rollback
order, [`backup-restore.md`](backup-restore.md) for restoring a dump.
+29
View File
@@ -0,0 +1,29 @@
# Query statistics (pg_stat_statements)
From v1.42 the bundled database loads the `pg_stat_statements` library, so the time spent in each query shape can be read from the database itself. Loading the library only collects statistics in shared memory; nothing is sent anywhere.
## Enable the view
The library is loaded through the `command:` of the `db` service in `docker-compose.yml`. The first `docker compose up` after updating restarts the database once. Then create the extension once:
```sh
docker compose exec db psql -U healthlog -d healthlog -c "CREATE EXTENSION IF NOT EXISTS pg_stat_statements;"
```
## Read it
The slowest query shapes by total time:
```sh
docker compose exec db psql -U healthlog -d healthlog -c "SELECT calls, round(total_exec_time) AS total_ms, round(mean_exec_time, 1) AS mean_ms, left(query, 120) AS query FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 15;"
```
The statements are stored with their parameters replaced by placeholders, so values from your records do not appear in the view.
## Reset
```sh
docker compose exec db psql -U healthlog -d healthlog -c "SELECT pg_stat_statements_reset();"
```
If you run your own PostgreSQL instead of the bundled one, add `pg_stat_statements` to `shared_preload_libraries` in its configuration and restart it before creating the extension.
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

+52 -5
View File
@@ -231,11 +231,58 @@ as something to log in as.
person it was sent to, so the admin notification diagnostic can answer
"who was this for" and "whose record was it about" separately.
There is no emancipation flow: no route, no control and no copy suggests
a managed profile can be turned into an ordinary account. The data model
leaves room for it (the credentials are nullable and the managed marker
can be cleared) and clearing that marker stops the notification fan-out,
but nothing in v1.37.0 exercises any of that.
### Handing a record over to its person
Since v1.42 a guardian can hand a managed profile over to the person it
describes, so the record becomes their own account with its whole history.
Nothing is copied or moved; the profile's row gets credentials, loses the
managed marker, and stops fanning reminders out to its guardians.
- A guardian with a fresh second factor creates a one-time link (valid for
one, seven or fourteen days, shown once) and proposes, for each guardian,
no access, view only or manage. Only a keyed hash of the link is stored,
at most one link per profile is open, and any guardian can withdraw it
without a second factor.
- The person opens the link, sees the record's name, the expiry and who
keeps which access, and chooses how they sign in. The claim runs as one
transaction: the link is consumed, the proposals are applied, open
invitations the record offered are withdrawn, and the disclaimer,
onboarding and AI consent are reset so the person gives them themselves.
After the first sign-in they confirm or change each guardian's access.
- How the person signs in depends on the instance:
| Instance | Ways in |
| --------------------------------- | -------------------------- |
| No SSO configured | Password |
| SSO configured, `OIDC_ONLY` unset | Password or single sign-on |
| `OIDC_ONLY="true"` | Single sign-on only |
The guardian's panel and the claim page both show which ways are open.
- **Through single sign-on** (v1.43) the person picks a username and
continues to the identity provider. When they come back, the provider's
`(issuer, sub)` pair is bound to the record in the same transaction, the
address is taken from the provider only when it asserts
`email_verified: true` (otherwise the account has no address), and no
password is set. Refused, with the record left exactly as it was:
a provider identity that already signs in to an account on this instance
(one identity, one account; the refusal is audited on that account as
`auth.oidc.claim_conflict`), a verified address another account holds,
a username taken in the meantime, a link that was used, withdrawn,
replaced or expired while the person was at the provider, and a browser
that is signed in to another account.
- The link never appears in a URL the server builds: the claim page sends it
in request bodies only, and the single-sign-on leg carries a sealed,
ten-minute, HttpOnly cookie that names the link by its id. The `/claim/`
path and `hlp_` tokens are scrubbed from logs.
- The iOS app does not claim. The person claims in a browser and then signs
in to the app as usual (with SSO, through the app's own SSO sign-in).
Audit rows: `managed_profile.handover.created` and `.revoked` on the record,
`managed_profile.claim_sso.started` when a single-sign-on claim starts,
`managed_profile.claimed` and `auth.claim` (with `method: "password"` or
`"sso"`) when it lands, and `managed_profile.handed_over` on each guardian's
own account.
## Seeing what happened
+110
View File
@@ -0,0 +1,110 @@
# Weather, air quality and pollen
The environment module stores, for each day, the weather and the outdoor air
at a coarse location: temperature, sunshine, daylight, rain, pressure,
humidity, and since v1.42 fine particles, ozone, nitrogen dioxide, the
European and US air-quality indices, UV, Saharan dust and six kinds of
pollen. Those days sit beside the person's own data: the correlations look
for patterns between them and sleep, heart-rate variability, blood pressure,
mood and symptoms, the Coach and connected MCP clients can read them, and the
dashboard shows a quiet note on a day with high pollen, a warm night or very
poor air.
The module is on by default. Nothing leaves the host until a person sets a
home location in Settings → Location & weather.
## What leaves the host
Three hosted Open-Meteo APIs, all keyless:
| Host | What for | Variable to self-host it |
| -------------------------------- | ------------------------------------ | --------------------------- |
| `archive-api.open-meteo.com` | daily weather (ERA5 reanalysis) | `OPENMETEO_BASE_URL` |
| `air-quality-api.open-meteo.com` | hourly air quality and pollen (CAMS) | `OPENMETEO_AIR_QUALITY_URL` |
| `geocoding-api.open-meteo.com` | the city search in settings | `OPENMETEO_GEOCODING_URL` |
A request carries the coordinates rounded to one decimal (about 11 km, the
size of a town), a date range and a timezone. No account id, no name, no
health value. Open-Meteo is open source; an operator who wants no egress at
all can run it and point the three variables at their own instance (a host
on the LAN is allowed).
## The data, and what it is not
- Weather comes from the ERA5 reanalysis on a grid of 9 to 25 kilometres. It
settles over a few days, so the newest stored day is usually yesterday or
the day before. Nothing here is a forecast.
- Air quality comes from the CAMS models: hourly on about 11 kilometres in
Europe, about 45 kilometres elsewhere. The feed starts in 2013; pollen, UV,
dust and aerosol depth only from about mid 2022, and pollen only in Europe.
A value the feed did not cover is stored as empty, never as zero.
- Hourly values become a day by fixed rules: means and maxima only for a day
with at least 18 of 24 hours, ozone as the highest 8-hour running mean,
UV only when the midday hours are all there.
- These are modelled outdoor conditions for an area, not anyone's personal
exposure. Every surface that shows them says so, and none of them says the
weather caused anything: a pattern is described as something that occurred
together.
## Switches
- **Per account:** Settings → Location & weather → Air quality and pollen.
On by default. Off stops the air-quality requests for that account and
leaves the air-quality values out of every surface; the days already
stored keep theirs.
- **For the whole instance:** `ENVIRONMENT_AIR_QUALITY_DISABLED=1` turns the
air-quality part off for every account (`1`, `true`, `yes` or `on`). The
weather keeps working. Settings then says the operator turned it off.
- The whole module is a normal module switch (Settings → Modules), and an
operator can make it unavailable like any other module.
All four variables are on the compose `environment:` whitelist.
## Request budget
The hosted APIs are free for non-commercial use up to 600 calls a minute,
5,000 an hour and 10,000 a day per instance, where a request for many
variables or many days counts as several calls. HealthLog keeps its own
counter in Postgres, shared by the web process and the worker, and stays
below 500 a minute, 4,000 an hour and 8,000 a day. A request the counter
refuses is not sent; the nightly run ends early and continues the next night.
For scale: a full two-year backfill of one account is about 150 calls, the
nightly refresh about 3 per account.
## Nightly fetch and catching up
The `environment-fetch` job runs at 02:10. For each account with a home it
refreshes the last seven days (weather and air quality together), then fills
up to 90 stored days that do not have air quality yet, newest first. An
instance that upgrades to v1.42 with two years of weather per account
catches up on air quality in about nine nights without any action. The
backfill button in settings fetches both for the range it is given.
## Locations are encrypted
Since v1.42 the home, each dated location period and each stored day keep
their coarse location sealed with the instance's encryption key (one value
per row, `{ lat, lon, label }`, under the label
`healthlog/environment-location/v1`). The boot-time encryption backfill seals
rows written by older releases and clears their readable columns; the
readable columns are dropped in a later release once the backfill reports
zero rows everywhere. Key rotation (`scripts/rotate-encryption-key.ts`, or the
in-app rotation) covers all three.
What the person sees is unchanged: the settings page and
`GET /api/environment` answer with the readable location, opened on the
server. The Coach and MCP read (`get_environment`) carry no coordinate and no
place name at all, only how many days were spent away from home.
Backups follow the usual split: a portable export carries the location
readable, a disaster-recovery backup carries it sealed, and a restore of
either seals it under the receiving host's key.
## Attribution
Open-Meteo data is CC BY 4.0, and CAMS data asks for the notice "Contains
modified Copernicus Atmosphere Monitoring Service information" (modified,
because the hourly values are folded into days). The settings page lists
both lines under the values, and the Coach and MCP read return them with
every result.
+68
View File
@@ -77,6 +77,26 @@ process (`pnpm dev` against a host Postgres) or a **custom compose** that
does not derive it — in that case uncomment and set `DATABASE_URL`
explicitly in `.env` (see the commented example there).
### Using an existing or shared Postgres
HealthLog can live in a database on a Postgres instance you already run.
It stores every time as UTC and pins each connection it opens to UTC
(`TimeZone=UTC`, sent with the libpq `options` startup parameter), so the
server's or the database's own `timezone` setting does not matter: an
instance set to `Europe/Berlin` works the same as one set to UTC. The app
checks the setting at startup and logs a warning naming the zone if a
connection still reports something else.
Behind PgBouncer with `DATABASE_SESSION_OPTIONS_DISABLED=1` (see
[Scaling](./scaling.md#postgres-connection-pool-sizing)) the app pins the
zone with `SET TIME ZONE 'UTC'` on each new connection instead; PgBouncer
keeps that setting per client in transaction pooling too. If the startup
warning still appears, set the database default as well:
```sql
ALTER DATABASE healthlog SET timezone TO 'UTC';
```
## 3. Bring the stack up
```bash
@@ -198,6 +218,11 @@ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build
## Troubleshooting
- **Where to look.** For a bug report, copy the support report (Admin,
System status) and the support info (Settings, About), and note the error
id the failure showed. Admin, App logs finds the event behind an error id.
What each contains, and what it never contains, is in
[`support.md`](./support.md).
- **`app` container exits with a Prisma migration error.** The
Postgres healthcheck races the migration on slow disks. Run
`docker compose restart app` once the database has stabilised —
@@ -341,3 +366,46 @@ For off-host encrypted backups to S3/R2/B2 see
[`docs/ops/backup-restore.md`](../ops/backup-restore.md); for rotating the
encryption key safely (keep the old key until zero rows remain on it) see
[`docs/ops/encryption-key-rotation.md`](../ops/encryption-key-rotation.md).
## Updates
A new image can bring database migrations, and `docker-entrypoint.sh`
applies them on the first start of that image. Most are additive, but some
rewrite data, and going back to the previous image does not undo them. The
only way back from a migration that went wrong is the dump you took before
it. So take one before every pull:
```bash
# 1. Dump the database (same command as under "Backup and restore").
docker compose exec -T db pg_dump -U healthlog healthlog \
| gzip > healthlog-before-update-$(date +%F).sql.gz
# 2. Pull the new image and recreate the app container.
docker compose pull app
docker compose up -d app
# 3. Confirm the running build.
curl -s http://localhost:3000/api/version
```
Keep the dump until the new version has run for a few days, and keep your
encryption key with it: a dump without the key cannot be read.
The start log says what is about to happen before the migrations run:
```text
HealthLog: 2 database migrations pending. Back up the database before updating (pg_dump, see the Updates section of docs/self-hosting/getting-started.md); a migration is not undone by going back to the previous image.
HealthLog: Running database migrations...
```
or `HealthLog: Database schema is up to date.` when the image brings none.
The line is a notice and does not pause the start: the migrations run right
after it. So the dump belongs before step 2, not after you see the line; by
then the database is already being migrated.
If an update to v1.43 stops at migration `0409_drop_plaintext_location`,
nothing was changed; the recovery is in
[`docs/ops/migration-0409-plaintext-location.md`](../ops/migration-0409-plaintext-location.md).
For the ordering on a host where you want the migration result before the
new container takes traffic, see [`docs/ops/deploy.md`](../ops/deploy.md).
+134
View File
@@ -179,6 +179,12 @@ HealthLog publishes to that topic.
**Server URL** (defaults to `https://ntfy.sh`; point it at your own
server if you self-host ntfy) and **Topic**, then save.
HealthLog publishes each message as JSON to the server's root URL (the topic
travels in the body, see ntfy's
[publish as JSON](https://docs.ntfy.sh/publish/#publish-as-json)), so titles
and button labels in any language arrive as written. A reverse proxy in front
of your own ntfy has to pass `POST /` through, not only `POST /<topic>`.
If your ntfy server requires auth, an access token can be attached; the
server URL is validated at save time and dialled through the egress guard
(`src/lib/notifications/senders/ntfy.ts`). A self-hosted ntfy on your own
@@ -234,6 +240,8 @@ choice existed keeps sending:
message are masked as they are on every other channel.
- `priority` is a word: `urgent` for an event flagged urgent, `high` for
medication reminders, `default` for everything else.
- `url`, when `APP_URL` is set, is the page the notification leads to (see
[Tap targets](#what-tapping-a-notification-opens)). Absent otherwise.
**Gotify** sends the body Gotify's `POST /message` endpoint expects:
@@ -450,6 +458,132 @@ Two gotchas worth knowing before you blame a stale token:
> in the web UI. If you only run the PWA, ignore it entirely — your
> medication reminders come from the server cron over the channels above.
## Tap targets, content and quick answers
### What tapping a notification opens
Every notification leads somewhere in the app. The server decides where,
once per event type, and every channel carries the same target:
| Notification | Opens |
| -------------------------------------------- | ---------------------------------------------------------------------------------- |
| Mood reminder | `/mood`, with the mood entry sheet already open |
| Morning briefing | the dashboard |
| Medication reminder, low supply, shared dose | `/medications` (low supply opens the medication) |
| Check-up and measurement reminders | `/checkups` |
| Proactive Coach nudge | the Coach conversation it started |
| Cycle reminders | `/cycle` (the dashboard with discreet notifications on) |
| New sign-in | Settings, Security |
| Integration sync failing | Settings, Integrations (an admin alert about another account opens the admin page) |
| Illness red flag | the illness episode |
| Other system alerts | the dashboard, or the admin status page for operator alerts |
How each channel carries it:
- **ntfy**: the message's `click` field, so tapping the notification opens
the page.
- **Telegram**: an **Open in HealthLog** button under the message, below any
action buttons. Telegram only accepts `https` button links on a public
host, so a plain-HTTP instance gets a text link instead, and an instance
on `localhost` gets no link.
- **Web Push**: the service worker opens the page, or focuses an open
HealthLog tab and navigates it there.
- **APNs**: the path rides the payload as `url`.
- **Webhook**: the generic body gains a `url` field; the Gotify body gains
`extras["client::notification"].click.url`, which the Gotify app opens.
- **Email**: a link line under the message.
ntfy, Telegram, the webhook and email need an absolute link, built from
`APP_URL` (or `NEXT_PUBLIC_APP_URL`). Without one those channels deliver as
before, just without a link. A notification about a profile you look after
as a Guardian opens the dashboard rather than the profile's page, because
your session opens on your own record and the record switcher lives there.
A discreet notification opens the dashboard as well, so its link names
nothing. The silent background sync push for medication intake is the only
notification without a link.
### Health figures in notifications
Settings, Notifications, **Health figures in notifications** decides what a
notification may say on a lock screen:
- **Off** (the default): a notification says what it is about and shows no
health figure. The morning briefing reads "Your morning briefing".
- **On**: notifications add their figures. The morning briefing becomes
"Morning score 78" with the day's one-line summary underneath.
Tap targets and quick answers work the same either way. The setting is
stored as `content.detail` (`generic` or `detailed`) in the notification
preferences (`PATCH /api/auth/me/notification-prefs`) and travels with the
account backup.
### Quick answers on the mood reminder
On **ntfy** the mood reminder carries three buttons, **Good**, **Okay** and
**Bad**. Tapping one logs a mood entry straight from the notification,
without opening the app, and clears the notification. They map onto the
five-point mood scale as Good = 4, Okay = 3, Bad = 2; the outer points
stay in the app. The entry is marked with the source **Notification**.
Each button calls `POST <APP_URL>/api/notifications/action/<token>`. The
token is the only credential, so it is built to be harmless on its own:
- it is signed with `API_TOKEN_HMAC_KEY` and names one account and one
answer, so it cannot be moved to another account or another answer;
- it expires six hours after the reminder was sent;
- the three answers of one reminder are one use between them: the first
tap logs, every later tap on that reminder is refused, and that stays
true when the logged entry is deleted and purged from the trash before
the six hours are up;
- the route is rate-limited before it reads the token, refuses when the
mood module has been turned off, and the token is scrubbed from logs.
The buttons need `APP_URL`, because the ntfy app calls the URL from your
phone. Your phone has to reach the instance at that address.
### Quick answers behind forward auth
If the instance sits behind a forward-auth gateway (Authelia, Authentik,
oauth2-proxy, Cloudflare Access and the like), the gateway answers the
button's request with its login page and the answer is never logged: the
ntfy app has no session with the gateway. Exempt the one path from the
gateway:
```
/api/notifications/action/*
```
The route does not rely on the gateway. The signed token is its only
credential, it is rate-limited before the token is read, and it can log one
mood answer for one account and nothing else. Every other path stays behind
the gateway.
What the token cannot protect against is the topic itself. The button URLs,
token included, travel through your ntfy server inside the message, and the
message is delivered to everyone subscribed to the topic. For the six hours
a token is valid, anyone who can read the topic can log that one answer on
your behalf. On the public `ntfy.sh` the topic name is the only access
control, so:
- use a long, random topic name and do not share it;
- better, run your own ntfy server with access control and give HealthLog
an access token that may only publish to your topic;
- to invalidate every quick-answer token at once, rotate
`API_TOKEN_HMAC_KEY`. The same key also hashes API tokens and session
cookies, so rotating it invalidates every issued API token and signs out
every browser session and the iOS app as well (see
[getting started](./getting-started.md)).
**Telegram** keeps its own keyboard on the mood reminder: the full 1 to 5
scale, a note and a "later" button.
**Web Push** offers no buttons. Most browsers show at most two actions,
which cannot carry a three-way answer, and Safari on iPhone shows none.
Tapping the notification opens the mood entry sheet instead.
**APNs**: the iOS app draws its own buttons from the notification category
it registers for the mood reminder.
## Which channel should I pick?
| Your setup | Recommended channel |
+14
View File
@@ -188,6 +188,20 @@ already`. Either raise `max_connections` on the Postgres side, lower
`DATABASE_POOL_MAX`, or front the database with PgBouncer in
transaction-pooling mode.
The app sets its statement timeout, `work_mem` and `TimeZone=UTC` on every
connection through the libpq `options` startup parameter. PgBouncer refuses that
parameter (`unsupported startup parameter: options`) unless it is listed
in `ignore_startup_parameters`. Either list it there, or set
`DATABASE_SESSION_OPTIONS_DISABLED=1` so the app sends no session
settings; set the timeout and `work_mem` on the database role instead
(`ALTER ROLE healthlog SET work_mem = '16MB'`) if you still want them.
The time zone is the exception: with the options off the app pins it with
`SET TIME ZONE 'UTC'` on each new connection, which PgBouncer tracks per
client, and warns at startup if a connection still reports another zone.
The measurement maintenance job (Admin, System status) then applies its own
settings with `SET` after connecting. Its concurrent index rebuild needs a
session, so run it through a session-mode pool or a direct connection.
### Overriding the per-container pool ceiling
With the bundled compose, set `DB_CONNECTION_LIMIT` in `.env`; it
+10
View File
@@ -112,6 +112,12 @@ Client registration essentials only — the IdP's own docs cover the rest.
MFA in the IdP's policy engine — that is the point of delegating
authentication. HealthLog-side 2FA remains available on top for the
step-up gates above.
- **Claiming a managed profile binds a fresh identity only.** When a
person claims a managed profile through SSO, their `(issuer, sub)` is
bound to that record, and only if no account on the instance already
holds it, and the provider's address is used only when it is verified
and not already taken. A claim can therefore never attach somebody's
existing sign-in to a second record or take over an existing account.
- **Linking is once.** There is no unlink/relink flow in this cut; the
pin is deliberately sticky. Moving an account to a new IdP identity is
an operator decision (a manual `users.oidc_issuer` / `users.oidc_sub`
@@ -134,6 +140,10 @@ Consequences to accept before turning it on:
- **Self-disabling on half-config.** The flag only takes effect while
the three provider vars are fully set, so a typo in the provider group
can never lock every user out.
- **Managed profiles are still handed over** (v1.43). The person claims the
record through the identity provider instead of setting a password; the
password claim is refused. See
[Handing a record over to its person](account-sharing.md#handing-a-record-over-to-its-person).
### Break-glass (IdP down or misconfigured)
+105
View File
@@ -0,0 +1,105 @@
# Reporting a problem
HealthLog does not send anything home, and nobody but the operator can read
an instance's logs. So a bug report has to carry what is needed to find the
fault. Two texts do that, and both are redacted before you see them.
| Who | Where | What it is |
| -------- | ---------------------------------------- | ------------------------------------------ |
| Everyone | Settings, About, **Copy support info** | Your app version, settings and connections |
| Operator | Admin, System status, **Support report** | The whole instance, accounts as numbers |
Add the **error id** of the failure if you have one, and paste both texts into
the [bug report](https://github.com/MBombeck/HealthLog/issues/new?template=bug_report.yml).
Read them before you post. They are built to contain nothing personal, but
you are the one publishing them.
## Error ids
When the server fails (a 5xx answer), the message shows **Error ID** and
eight characters, for example `3f9a2c1b`, with a copy button that copies the
full id. A page that fails to render shows one too when the server has one
to give: the request id of the failed call, or the digest the server printed
beside the stack in its log.
The id is the request id of the failing call. Every error answer of the API
carries it in `meta.requestId` and in the `x-request-id` header. An incoming
`x-request-id` is only adopted when it consists of letters, digits and hyphens
(8 to 64 characters); anything else is replaced with a fresh id.
The operator finds the event under **Admin, App logs** by typing the id (or
its first 8 characters) into **Error ID**. A search by id reads the second log
buffer, which keeps the last 1,000 warnings and errors of the process. Ordinary
traffic cannot push them out, so an error stays findable for much longer than
in the main buffer of 500 events. Both buffers live in memory: a restart
empties them, and a split `web` / `worker` deployment has one per process.
The same search works on the API:
```
GET /api/admin/app-logs?requestId=3f9a2c1b&ring=problems
```
A 4xx answer (a refused input, a missing permission) shows no id. Its message
says what to change.
## Support info (everyone)
**Settings, About, Copy support info** puts together, in your browser:
- server version and build, and your browser;
- language, time zone and the modules you have switched on;
- the AI state: whether a provider is set up, your usage level, and which AI
features are available and why not;
- for each connected source (Withings, Oura, and so on), its health state,
reason, last success, newest data and the last recorded sync attempts;
- the last five error ids of this browser session.
It never contains your username, email address, name, server address,
tokens, recorded values or record ids. Nothing is sent anywhere; the text
goes to your clipboard.
## Support report (operator)
**Admin, System status, Support report** creates a text with **Copy** and
**Download**. It is created when you press a button, not on page load, and
shown above the buttons so you can read it first. It contains:
- **Build:** version, build SHA, build time, Node, process type, heap limit,
uptime.
- **Database:** PostgreSQL version, server time zone, size, last applied
migration, unfinished migrations.
- **Configuration**, as yes or no only: secure cookie flag, OIDC, OIDC only,
Loki, GlitchTip, off-host backup, APNs, Web Push, SMTP, GeoLite2, the number
of `NOTIFICATION_PRIVATE_ORIGINS` entries, operator AI key, central Codex,
the AI provider types accounts chose, demo mode.
- **Jobs:** whether the worker runs in this process and when it last
reported, the newest finished job, and every queue with failures in the
last 72 hours with an error class (`timeout`, `TypeError`, `P2002`,
`ECONNREFUSED`, an HTTP status), never the message.
- **Connections:** per source, how many accounts are in each health state and
reason, which accounts are not `ok`, and open watchdog incidents.
- **AI:** the usage level default and ceiling, accounts per usage level, and
the provider ledger per provider type (working, failing, credential
refused, paused).
- **Notifications:** per channel, delivered, failed and skipped in the last 7
days, with the most frequent failure reasons.
- **Backups:** newest in-database copy, the last weekly run, off-host
freshness, and the last restore drill.
- **Recent warnings and errors:** the 20 most frequent action names in the
warning and error buffer of this process, with count, last seen and error
class.
Accounts appear as `Account 1`, `Account 2` and so on, numbered by creation.
The report never contains an email address, a username, an account id, a
recorded value, a token, or the host name of another service. As a second
layer, the finished text is scrubbed for the instance's own usernames,
emails, names and ids, for tokens, URLs, email addresses, ids, IP addresses
and host names before it is shown.
## Logs beyond the report
`docker compose logs app` prints every wide event as one JSON line;
`LOG_LEVEL=debug` adds debug events. With `LOKI_ENDPOINT` set the same events
go to Loki, and GlitchTip (Admin, General) receives unexpected server errors.
Each line carries `request_id`, so an error id finds its line there too.
+55
View File
@@ -0,0 +1,55 @@
# Searching your record
The command palette finds pages and settings, and it searches what you have
recorded.
## Opening it
Press **⌘K** on a Mac or **Ctrl K** elsewhere, or tap the magnifier at the
top right, and start typing.
## What it finds
Below the pages and settings, a section **In your records** lists the
entries that match what you typed:
- visits (procedures included)
- conditions
- documents, by title, file name and the text read from them
- vaccinations
- lab results
- medications
- symptoms
- workouts
- notes on mood entries, readings and illness days
Each hit opens where the entry lives: a visit on the check-ups page, a lab
result on its analyte, a note on a mood entry or a reading on its day, a note
on an illness day in its illness.
## How matching works
Case and accents do not matter, so "schulter", "Schulter" and "Schülter"
find the same entries. Umlauts can be typed as `ae`, `oe` and `ue` (and `ß`
as `ss`), which helps on a keyboard without them.
## Very large records
The search reads each kind of entry newest first and stops after a fixed
number of entries and a short time limit, so it stays quick on a record that
holds years of data. When it stopped before reaching the oldest entries, the
list says so. A more specific search term usually finds an older entry.
## Shared records
When someone has shared their whole record with you, the search works inside
that record too. An account shared with you section by section cannot be
searched: the search lays every section side by side, which a partial share
does not open.
## Privacy
What you type is sent to your instance in the body of the request, not in the
address, so it does not end up in a reverse proxy's access log or in the
browser history. The server does not log the search text either, only its
length and how many hits it found.
+29
View File
@@ -605,6 +605,35 @@ async function installA11yMocks(page: Page) {
pdfSupported: true,
}),
);
// The lab list reads one row per marker from the overview route.
await page.route("**/api/labs/overview", (route) =>
fulfilJson(route, {
markers: [
{
key: "analyte:a11y ldl",
count: 1,
trend: [2.4],
latest: {
id: "a11y-lab",
biomarkerId: null,
panel: "A11y panel",
analyte: "A11y LDL",
value: 2.4,
valueText: null,
unit: "mmol/L",
referenceLow: 0,
referenceHigh: 3,
takenAt: "2026-07-19T12:00:00.000Z",
source: "MANUAL",
hasNote: false,
rangeStatus: "inRange",
createdAt: "2026-07-19T12:00:00.000Z",
updatedAt: "2026-07-19T12:00:00.000Z",
},
},
],
}),
);
await page.route("**/api/labs?*", (route) =>
fulfilJson(route, {
results: [
+5 -1
View File
@@ -74,7 +74,11 @@ test("J5: withdrawing AI consent deletes the notes written under it", async ({
);
await page.locator('[data-slot="ai-consent-withdraw-confirm"]').click();
expect((await withdrawn).status()).toBe(200);
await expect(page.locator('[data-slot="ai-consent-grant"]')).toBeVisible();
// On a local model nothing needs the consent, so once withdrawn there is
// nothing to withdraw and nothing to ask.
await expect(page.locator('[data-slot="ai-consent-withdraw"]')).toHaveCount(
0,
);
await openOverview(page);
await expect(page.getByText(marker)).toHaveCount(0);
-8
View File
@@ -89,14 +89,6 @@ test.describe("central codex opt-in switch (mocked)", () => {
}),
);
await page.route("**/api/consent/ai/web", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: { ok: true }, error: null }),
}),
);
// The opt-in write — flips the mocked state so a re-read reflects it.
await page.route("**/api/auth/me/use-central-codex", (route) => {
useCentralCodex = true;
+3 -3
View File
@@ -33,7 +33,7 @@ import {
* 3. Nothing opens by itself: the row stays closed (`aria-expanded`
* false) in every state, after the answer and after a reload; a row
* opened during the run closes when the answer is done.
* 4. After the answer the row reads "Thought process · 4 steps" and the
* 4. After the answer the row reads "Thought process (4 steps)" and the
* table the answer points at renders as its chart.
* 5. A tap opens the steps; a reasoning round shows its summary under
* its title.
@@ -435,7 +435,7 @@ test.describe("Coach live trail", () => {
await expect(settled).toHaveAttribute("data-state", "done");
await expect(
settled.locator('[data-slot="coach-turn-steps-done"]'),
).toHaveText("Thought process · 4 steps");
).toHaveText("Thought process (4 steps)");
await expect(settled.locator(".text-shimmer")).toHaveCount(0);
const settledToggle = settled.locator(
'[data-slot="coach-turn-steps-toggle"]',
@@ -506,7 +506,7 @@ test.describe("Coach live trail", () => {
await expect(reloaded).toHaveAttribute("data-state", "done");
await expect(
reloaded.locator('[data-slot="coach-turn-steps-done"]'),
).toHaveText("Thought process · 4 steps");
).toHaveText("Thought process (4 steps)");
const reloadedToggle = reloaded.locator(
'[data-slot="coach-turn-steps-toggle"]',
);
+188
View File
@@ -0,0 +1,188 @@
import { expect, test } from "./setup/test";
import type { Route } from "@playwright/test";
import { randomBytes } from "node:crypto";
import { setTimeout as delay } from "node:timers/promises";
import { hash } from "@node-rs/argon2";
import pg from "pg";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
const KEEP_ID = "conversation-delete-keep";
const DROP_ID = "conversation-delete-drop";
function row(id: string, title: string) {
return {
id,
title,
createdAt: "2026-07-20T10:00:00.000Z",
updatedAt: "2026-07-20T10:00:00.000Z",
messageCount: 2,
fenced: false,
attachments: [],
documentTitle: null,
};
}
/**
* A deleted conversation must stay deleted. The delete waits out an undo
* window before it is sent; a reload inside that window used to drop it, and
* once the window ran out the row showed again until the refetch landed.
*/
test.describe("Coach conversation delete", () => {
test.beforeEach(async ({ page }) => {
await serveAiBlock(page, aiBlockAvailable());
});
test.use({ storageState: STORAGE_STATE_PATH });
async function serveConversations(
page: import("@playwright/test").Page,
deleteDelayMs: number,
) {
const state = {
rows: [row(KEEP_ID, "Kept thread"), row(DROP_ID, "Dropped thread")],
deletes: [] as string[],
};
// Context-level, so a `keepalive` request sent while the page unloads is
// still answered here.
await page
.context()
.route(
/\/api\/insights\/chat(?:\/[^?]+)?(?:\?|$)/,
async (route: Route) => {
const request = route.request();
const url = new URL(request.url());
if (request.method() === "DELETE") {
const id = decodeURIComponent(url.pathname.split("/").pop() ?? "");
state.deletes.push(id);
state.rows = state.rows.filter((r) => r.id !== id);
if (deleteDelayMs > 0) await delay(deleteDelayMs);
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: { deleted: true }, error: null }),
});
}
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { conversations: state.rows, nextCursor: null },
error: null,
}),
});
},
);
return state;
}
test("the row does not come back when the window runs out", async ({
page,
}) => {
// A slow DELETE holds the refetch back, which is when the row used to
// reappear.
const state = await serveConversations(page, 2_000);
await page.goto("/coach/conversations", { waitUntil: "domcontentloaded" });
const items = page.locator('[data-slot="coach-conversations-item"]');
await expect(items).toHaveCount(2, { timeout: 10_000 });
await items
.filter({ hasText: "Dropped thread" })
.locator('[data-slot="coach-conversations-delete"]')
.click();
await expect(items).toHaveCount(1);
await expect
.poll(() => state.deletes, { timeout: 15_000 })
.toEqual([DROP_ID]);
// Sampled while the DELETE is still in flight.
for (let i = 0; i < 6; i += 1) {
expect(await items.count()).toBe(1);
await delay(250);
}
await expect(items).toContainText("Kept thread");
});
});
/**
* The real server and database, not a mock: the request that matters here is
* sent with `keepalive` while the page unloads, which Playwright's routing
* never sees. The account is the spec's own, so the conversation it seeds
* cannot surface in another spec's Coach.
*/
test.describe("Coach conversation delete against the database", () => {
test.use({ storageState: { cookies: [], origins: [] } });
test("a reload inside the undo window still deletes", async ({
page,
}, testInfo) => {
const dbUrl = process.env.DATABASE_URL;
test.skip(!dbUrl, "DATABASE_URL is required to seed the conversation");
const suffix = `${testInfo.project.name}-${randomBytes(4).toString("hex")}`;
const username = `e2e-coach-delete-${suffix}`;
const password = "Cd5!Vq8nTz3wLp7K";
const userId = `e2e-coach-delete-user-${suffix}`;
const conversationId = `e2e-coach-delete-${suffix}`;
const pool = new pg.Pool({ connectionString: dbUrl });
try {
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role, created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed)
VALUES ($1, $2, $3, $4, 'USER', now(), now(), now(), true)`,
[
userId,
username,
`${username}@healthlog.test`,
await hash(password, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
}),
],
);
// A legacy readable title keeps the seed free of the encryption key.
await pool.query(
`INSERT INTO coach_conversations (id, user_id, title, updated_at)
VALUES ($1, $2, 'Dropped thread', now())`,
[conversationId, userId],
);
const login = await page.request.post("/api/auth/login", {
data: { email: username, password },
});
expect(login.status()).toBe(200);
await page.goto("/coach/conversations", {
waitUntil: "domcontentloaded",
});
const items = page.locator('[data-slot="coach-conversations-item"]');
await expect(items).toHaveCount(1, { timeout: 15_000 });
await items.locator('[data-slot="coach-conversations-delete"]').click();
await expect(items).toHaveCount(0);
// Well inside the undo window.
await page.reload({ waitUntil: "domcontentloaded" });
await expect
.poll(async () => {
const { rowCount } = await pool.query(
"SELECT 1 FROM coach_conversations WHERE id = $1",
[conversationId],
);
return rowCount;
})
.toBe(0);
await expect(
page.locator('[data-slot="coach-conversations-empty"]'),
).toBeVisible({ timeout: 15_000 });
await expect(items).toHaveCount(0);
} finally {
await pool.query("DELETE FROM users WHERE id = $1", [userId]);
await pool.end();
}
});
});
+333 -155
View File
@@ -4,25 +4,32 @@ import type { Locator, Page, Request, Route, TestInfo } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
import { mockDay } from "./setup/day-mock";
/**
* The Coach page frame: the conversations panel on the right, its toggle in
* the top bar, the settings gear in the panel header, and New chat at the
* panel's bottom right.
* The Coach page frame: the conversation, the conversations panel right of
* it, and the day right of that; New conversation floating in the
* conversation itself.
*
* Contracts under test, at 1440, 1280 and 390 px:
* Contracts under test, at 1920, 1440, 1280 and 390 px:
*
* 1. From 1280 px the panel docks beside the thread, open on a first visit,
* and the reading column (thread and composer) is exactly as wide with
* the panel open as with it closed. The choice survives a reload. The
* panel is flush with the viewport's right edge (no scrollbar gutter
* beside it) and runs from the top of the window to the bottom; the
* top bar ends at its left edge and the panel's header border is the
* top bar's. The toggle is the bar's last item, within 16 px of its
* right edge, open and shut; shut, no pixel of the panel shows and the
* bar runs to the window's edge. New chat is a round 48 px plus.
* 2. Escape inside the docked panel closes it and returns focus to the
* toggle.
* 1. From 1280 px the panel docks beside the thread, open on a first visit.
* From 1440 px the reading column (thread and composer) is exactly as
* wide with the panel open as folded; at 1280 the two strips leave the
* open column below its cap, so folding widens it up to the cap. Its 40 px strip stays at the right edge,
* left of the day's, open or not, and never moves: a click opens the
* panel left of the strips, a second click shuts it; the choice
* survives a reload. The panel is flush with the strips and runs from
* the top of the window to the bottom; the top bar ends at its left
* edge and the panel's header border is the top bar's. Both strips run
* the full height of the window, from its top edge, and the search
* magnifier ends the top bar in front of them, never covered. The strip
* is the docked panel's only fold control (the header has none); focus
* moves between the strip and the panel's heading, and Escape inside
* the open panel folds it.
* 2. New conversation is a round 56 px button in the conversation column,
* never over the panel, never over the composer's field, and absent
* from the panel's header.
* 3. A row's menu renames by keyboard (Escape cancels without closing the
* panel, Enter saves once) and deletes with an Undo that keeps the
* DELETE from ever leaving; a delete left alone commits once.
@@ -31,10 +38,13 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
* can see" and drops the param from the URL.
* 5. axe finds nothing on the open panel or the open settings, in the
* light and the dark theme.
* 6. At 390 px the panel is a dialog sheet with New chat 16 px from the
* right edge (24 px in the docked panel); Escape closes it and focus returns to the toggle. The gear
* opens a bottom sheet that closes on Escape back to the gear. Nothing
* scrolls sideways.
* 6. The day docks right of the panel. At 1440 only one of the two is
* open: a day folds the panel, opening the panel folds the day. At 1920
* both stay open and the conversation keeps at least 560 px.
* 7. At 390 px the panel is a dialog sheet opened from the top bar's
* toggle; Escape closes it and focus returns to the toggle. The gear
* opens a bottom sheet that closes on Escape back to the gear. New
* conversation sits above the composer. Nothing scrolls sideways.
*
* Every Coach route is stubbed; the provider chain and provider config are
* stubbed too, so the model section renders the same on every machine.
@@ -173,9 +183,6 @@ async function mockCoach(page: Page): Promise<CoachMock> {
await page.route("**/api/coach/about-me/questions*", (route) =>
fulfilJson(route, { questions: [] }),
);
await page.route("**/api/insights/coach/seeded-question*", (route) =>
fulfilJson(route, { signal: null }),
);
await page.route("**/api/insights/provider-chain", (route) =>
route.request().method() === "GET"
? fulfilJson(route, {
@@ -212,6 +219,16 @@ async function mockCoach(page: Page): Promise<CoachMock> {
}
const toggle = (page: Page) => page.locator('[data-slot="coach-panel-toggle"]');
const collapse = (page: Page) =>
page.locator('[data-slot="coach-panel-collapse"]');
const heading = (page: Page) =>
page.locator('[data-slot="coach-panel-heading"]');
const expand = (page: Page) =>
page.locator('[data-slot="coach-panel-strip-toggle"]');
const strip = (page: Page) => page.locator('[data-slot="coach-panel-strip"]');
const dayStrip = (page: Page) => page.locator('[data-slot="day-strip"]');
const fab = (page: Page) => page.locator('[data-slot="coach-new-chat-fab"]');
const dayPanel = (page: Page) => page.locator('[data-slot="day-panel"]');
const panel = (page: Page) =>
page.locator('[data-slot="coach-conversations-panel"]');
@@ -240,95 +257,129 @@ async function expectNoSidewaysScroll(page: Page) {
}
/**
* The docked panel is flush with the window: its right edge is the
* viewport's (no scrollbar gutter beside it), it runs from the top of the
* window to the bottom, the top bar ends at its left edge, its header row's
* bottom border is the top bar's, and its own border is the left one only.
* The docked panel is flush with its strips: the conversations' strip sits
* right of the panel and the day's right of that, against the viewport's
* right edge (no scrollbar gutter beside them). The panel runs from the top
* of the window to the bottom, the top bar ends at its left edge, an open
* panel's header row has the top bar's bottom border, and its own border
* is the left one only. Both strips run the full height of the window, and
* the search magnifier at the end of the top bar sits in front of them,
* whole and on top.
*/
async function expectPanelFlush(page: Page, width: number, height: number) {
const geometry = await page.evaluate(() => {
const aside = document.querySelector(
const box = (selector: string) =>
document.querySelector(selector)?.getBoundingClientRect() ?? null;
const outer = document.querySelector(
'[data-slot="coach-conversations-panel"]',
)!;
const inner = aside.firstElementChild as HTMLElement;
const header = aside.querySelector(
'[data-slot="coach-conversations-panel-header"]',
)!;
const bar = document.querySelector('[data-slot="top-bar"]')!;
const inner = outer.querySelector("aside");
const style = inner ? getComputedStyle(inner) : null;
const main = document.getElementById("main-content")!;
const style = getComputedStyle(inner);
const box = aside.getBoundingClientRect();
const barBox = bar.getBoundingClientRect();
const panel = outer.getBoundingClientRect();
return {
right: box.right,
top: box.top,
bottom: box.bottom,
left: box.left,
headerBottom: header.getBoundingClientRect().bottom,
barBottom: barBox.bottom,
barRight: barBox.right,
panel: {
left: panel.left,
right: panel.right,
top: panel.top,
bottom: panel.bottom,
},
header:
box('[data-slot="coach-conversations-panel-header"]')?.bottom ?? null,
bar: box('[data-slot="top-bar"]')!,
list: box('[data-slot="coach-panel-strip"]')!,
listButton: box('[data-slot="coach-panel-strip-toggle"]')!,
day: box('[data-slot="day-strip"]')!,
dayButton: box('[data-slot="day-strip-toggle"]')!,
search: box('[data-slot="command-palette-trigger"]')!,
searchOnTop: (() => {
const r = document
.querySelector('[data-slot="command-palette-trigger"]')!
.getBoundingClientRect();
return (
document
.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
?.closest('[data-slot="command-palette-trigger"]') != null
);
})(),
dayDock: box("#day-docked-panel")!,
gutter: main.offsetWidth - main.clientWidth,
borders: [
style.borderTopWidth,
style.borderRightWidth,
style.borderBottomWidth,
style.borderLeftWidth,
],
borders: style
? [
style.borderTopWidth,
style.borderRightWidth,
style.borderBottomWidth,
style.borderLeftWidth,
]
: null,
};
});
expect(geometry.right).toBe(width);
expect(geometry.top).toBe(0);
expect(geometry.bottom).toBe(height);
expect(geometry.barRight).toBe(geometry.left);
expect(geometry.headerBottom).toBe(geometry.barBottom);
expect(geometry.day.right).toBe(width);
expect(geometry.list.right).toBe(geometry.day.left);
expect(Math.round(geometry.list.width)).toBe(40);
expect(Math.round(geometry.day.width)).toBe(40);
// Conversations, then the day (open or slid to nothing), then the strips.
expect(geometry.panel.right).toBe(geometry.dayDock.left);
expect(geometry.dayDock.right).toBe(geometry.list.left);
expect(geometry.panel.top).toBe(0);
expect(geometry.panel.bottom).toBe(height);
expect(geometry.bar.right).toBe(geometry.panel.left);
// The strips run the full height, from the top edge of the window.
for (const button of [geometry.listButton, geometry.dayButton]) {
expect(button.top).toBe(0);
expect(button.bottom).toBe(height);
}
// The magnifier ends the top bar, left of the strips, whole and on top.
expect(geometry.search.top).toBeGreaterThanOrEqual(0);
expect(geometry.search.bottom).toBeLessThanOrEqual(geometry.bar.bottom);
expect(geometry.search.right).toBeLessThanOrEqual(geometry.bar.right);
expect(geometry.search.right).toBeLessThanOrEqual(geometry.list.left);
expect(geometry.searchOnTop).toBe(true);
if (geometry.header !== null) {
expect(geometry.header).toBe(geometry.bar.bottom);
expect(geometry.borders).toEqual(["0px", "0px", "0px", "1px"]);
}
expect(geometry.gutter).toBe(0);
expect(geometry.borders).toEqual(["0px", "0px", "0px", "1px"]);
}
/** Where a strip stands on screen. */
async function stripBox(locator: Locator) {
const box = (await locator.boundingBox())!;
return { x: Math.round(box.x), width: Math.round(box.width) };
}
/**
* The toggle is the last item of the top bar (the account menu that follows
* it on phones is not rendered from `md`), and its right edge is within
* 16 px of the bar's right edge.
* New conversation floats in the conversation column: a round 56 px button
* with a plus and its name, inside the column (never over a docked panel
* right of it) and clear of the composer's field. The panel's header has no
* such control.
*/
async function expectToggleAtBarEnd(page: Page) {
const at = await page.evaluate(() => {
const bar = document.querySelector('[data-slot="top-bar"]')!;
const shown = [...bar.querySelectorAll("*")].filter(
(el) => el.getClientRects().length > 0,
);
const toggle = bar.querySelector('[data-slot="coach-panel-toggle"]')!;
const rightmost = Math.max(
...shown.map((el) => el.getBoundingClientRect().right),
);
return {
gap:
bar.getBoundingClientRect().right -
toggle.getBoundingClientRect().right,
last: toggle.getBoundingClientRect().right >= rightmost,
lastChild:
toggle.closest('[data-slot="top-bar-actions"]')?.lastElementChild ===
toggle,
};
});
expect(at.last, "nothing in the bar stands right of the toggle").toBe(true);
expect(at.lastChild).toBe(true);
expect(at.gap).toBeGreaterThanOrEqual(0);
expect(at.gap).toBeLessThanOrEqual(16);
}
/** New chat: a round 48 px button with the plus glyph. */
async function expectFab(page: Page) {
const fab = panel(page).locator('[data-slot="coach-panel-new-chat"]');
const box = (await fab.boundingBox())!;
expect([Math.round(box.width), Math.round(box.height)]).toEqual([48, 48]);
expect(
await fab.evaluate((el) =>
parseFloat(getComputedStyle(el).borderTopLeftRadius),
),
"round",
).toBeGreaterThanOrEqual(24);
await expect(fab.locator("svg.lucide-plus")).toHaveCount(1);
await expect(fab).toHaveAttribute("aria-label", "New chat");
async function expectNewChatFab(page: Page) {
await expect(fab(page)).toHaveCount(1);
await expect(fab(page)).toHaveAttribute("aria-label", "New conversation");
await expect(fab(page).locator("svg.lucide-plus")).toHaveCount(1);
await expect(
panel(page).locator('[data-slot="coach-panel-new-chat"]'),
).toHaveCount(0);
const box = (await fab(page).boundingBox())!;
expect(Math.round(box.width)).toBe(56);
expect(Math.round(box.height)).toBe(56);
const main = (await page
.locator('[data-slot="coach-page-main"]')
.boundingBox())!;
expect(box.x).toBeGreaterThanOrEqual(main.x);
expect(box.x + box.width).toBeLessThanOrEqual(main.x + main.width);
const field = (await page
.locator(
'[data-slot="coach-page-composer"] [data-slot="coach-input-textarea"]',
)
.boundingBox())!;
const overlaps =
box.x < field.x + field.width &&
box.x + box.width > field.x &&
box.y < field.y + field.height &&
box.y + box.height > field.y;
expect(overlaps, "the button leaves the composer's field free").toBe(false);
}
/** Rendered heights of the docked panel's header buttons and first row. */
@@ -425,21 +476,25 @@ test.describe("Coach page frame", () => {
// First visit: open.
await expect(panel(page)).toHaveAttribute("data-state", "open");
await waitForWidth(panel(page), 288);
await expect(toggle(page)).toHaveAttribute("aria-expanded", "true");
await expect(toggle(page)).toHaveAttribute(
// Docked, the strip is the one fold control: the top bar and the
// panel's header carry none.
await expect(toggle(page)).toHaveCount(0);
await expect(collapse(page)).toHaveCount(0);
await expect(expand(page)).toHaveAttribute(
"aria-controls",
"coach-conversations-panel",
);
const open = await columnWidths(page);
expect(open.thread).toBeCloseTo(672, 0);
// The full 672 px column at 1440; at 1280 the two strips take 80 px
// of it and the column stays well above the 560 px floor.
if (width === 1440) expect(open.thread).toBeCloseTo(672, 0);
else expect(open.thread).toBeGreaterThanOrEqual(560);
expect(Math.abs(open.composer - open.thread)).toBeLessThanOrEqual(1);
// The panel sits right of the column, never over it.
const aside = (await panel(page).boundingBox())!;
expect(aside.x).toBeGreaterThanOrEqual(open.threadX + open.thread);
await expectPanelFlush(page, width, 900);
// Open, the toggle is the top bar's last item, against the panel.
await expectToggleAtBarEnd(page);
await expectFab(page);
await expectNewChatFab(page);
// The bar says where the reader is: the page, then the conversation.
await expect(
page.locator('[data-slot="coach-top-bar-trail"]'),
@@ -447,37 +502,37 @@ test.describe("Coach page frame", () => {
await expectNoSidewaysScroll(page);
await shot(page, testInfo, `coach-frame-${width}-open`);
await toggle(page).click();
// The strip stands still through a fold and an open.
const stripOpen = await stripBox(strip(page));
await expect(expand(page)).toHaveAttribute("aria-expanded", "true");
// Folded: the panel slides to nothing, the strip takes the focus, and
// the top bar ends at the strips.
await expand(page).click();
await expect(panel(page)).toHaveAttribute("data-state", "closed");
await waitForWidth(panel(page), 0);
await expect(toggle(page)).toHaveAttribute("aria-expanded", "false");
const closed = await columnWidths(page);
// Closed, the panel is entirely off-screen, the top bar runs to the
// window's edge and the toggle is still its last item.
await expect
.poll(() =>
page.evaluate(
() =>
document
.querySelector('[data-slot="top-bar"]')!
.getBoundingClientRect().right,
),
)
.toBe(width);
expect(
await panel(page).evaluate((el) => {
const box = el.getBoundingClientRect();
return box.width === 0 || box.left >= window.innerWidth;
}),
"no pixel of the shut panel shows",
).toBe(true);
await expectToggleAtBarEnd(page);
expect(Math.abs(closed.thread - open.thread)).toBeLessThanOrEqual(1);
expect(Math.abs(closed.composer - open.composer)).toBeLessThanOrEqual(1);
// Closed, nothing inside the panel is reachable.
expect(await stripBox(strip(page))).toEqual(stripOpen);
await expect(expand(page)).toBeFocused();
await expect(expand(page)).toHaveAttribute("aria-expanded", "false");
await expect(expand(page)).toHaveAttribute(
"aria-label",
"Show conversations",
);
await expect(
panel(page).locator('[data-slot="coach-history-select"]').first(),
).not.toBeInViewport();
panel(page).locator('[data-slot="coach-history-select"]'),
).toHaveCount(0);
await expectPanelFlush(page, width, 900);
const closed = await columnWidths(page);
if (width === 1440) {
expect(Math.abs(closed.thread - open.thread)).toBeLessThanOrEqual(1);
} else {
// At 1280 the strips leave the open column below its 672 px cap, so
// folding the list gives the column back what it lacked, and no more.
expect(closed.thread).toBeGreaterThanOrEqual(open.thread);
expect(closed.thread).toBeLessThanOrEqual(672 + 1);
}
expect(Math.abs(closed.composer - closed.thread)).toBeLessThanOrEqual(1);
await expectNewChatFab(page);
await expectNoSidewaysScroll(page);
await shot(page, testInfo, `coach-frame-${width}-closed`);
@@ -485,8 +540,26 @@ test.describe("Coach page frame", () => {
await openCoach(page);
await expect(panel(page)).toHaveAttribute("data-state", "closed");
// Open again, then Escape from inside closes it, focus to the toggle.
await toggle(page).click();
// A click on the strip opens it, a second click shuts it, and the
// strip does not move either time.
await expand(page).click();
await expect(panel(page)).toHaveAttribute("data-state", "open");
expect(await stripBox(strip(page))).toEqual(stripOpen);
await expand(page).click();
await expect(panel(page)).toHaveAttribute("data-state", "closed");
await expect(expand(page)).toBeFocused();
expect(await stripBox(strip(page))).toEqual(stripOpen);
// Opened from the keyboard, focus lands on the panel's heading; Escape
// from inside folds it again and focus goes back to the strip.
await expand(page).focus();
await page.keyboard.press("Enter");
await expect(panel(page)).toHaveAttribute("data-state", "open");
await expect(heading(page)).toBeFocused();
await page.keyboard.press("Escape");
await expect(panel(page)).toHaveAttribute("data-state", "closed");
await expect(expand(page)).toBeFocused();
await page.keyboard.press("Enter");
await expect(panel(page)).toHaveAttribute("data-state", "open");
await panel(page)
.locator('[data-slot="coach-history-select"]')
@@ -494,10 +567,107 @@ test.describe("Coach page frame", () => {
.focus();
await page.keyboard.press("Escape");
await expect(panel(page)).toHaveAttribute("data-state", "closed");
await expect(toggle(page)).toBeFocused();
await expect(expand(page)).toBeFocused();
});
}
test("1440: the day and the conversations take turns, focus included", async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockCoach(page);
await mockDay(page);
const day = "2026-06-01";
await page.goto(`/coach?c=frame-bp&day=${day}`, {
waitUntil: "domcontentloaded",
});
await expect(
page.locator('[data-slot="coach-message-thread"]').getByText(ANSWER),
).toBeVisible({ timeout: 15_000 });
// The day opens docked, left of the strips, and the conversations
// close for it; both strips stay.
await expect(dayPanel(page)).toHaveAttribute("data-shell", "docked");
await expect(panel(page)).toHaveAttribute("data-state", "closed");
await waitForWidth(panel(page), 0);
const dayBox = (await dayPanel(page).boundingBox())!;
const listStrip = (await strip(page).boundingBox())!;
expect(Math.round(dayBox.x + dayBox.width)).toBe(Math.round(listStrip.x));
expect(Math.round(listStrip.x + listStrip.width)).toBe(1400);
await expect(dayStrip(page)).toHaveAttribute("data-state", "open");
await expectPanelFlush(page, 1440, 900);
await expectNewChatFab(page);
expect(
(await page.locator('[data-slot="coach-page-main"]').boundingBox())!
.width,
).toBeGreaterThanOrEqual(540);
await expectNoSidewaysScroll(page);
await shot(page, testInfo, "coach-frame-1440-day");
// Opening the conversations closes the day; its strip keeps the day,
// focus goes to the conversations' heading, the day is out of the
// URL, and the conversation stays the same.
await expand(page).click();
await expect(panel(page)).toHaveAttribute("data-state", "open");
await expect(dayPanel(page)).toHaveCount(0);
await expect(dayStrip(page)).toHaveAttribute("data-day", day);
await expect(dayStrip(page)).toHaveAttribute("data-state", "closed");
await expect(heading(page)).toBeFocused();
await expect(page).toHaveURL(/\/coach\?c=frame-bp$/);
await expectNewChatFab(page);
await shot(page, testInfo, "coach-frame-1440-list");
// And back: the day's strip opens the day, the conversations close.
await page.locator('[data-slot="day-strip-toggle"]').click();
await expect(dayPanel(page)).toHaveAttribute("data-shell", "docked");
await expect(panel(page)).toHaveAttribute("data-state", "closed");
await expect(page).toHaveURL(new RegExp(`c=frame-bp&day=${day}$`));
// Switching conversations keeps the day open.
await expand(page).click();
await page.locator('[data-slot="day-strip-toggle"]').click();
await expect(dayPanel(page)).toBeVisible();
await fab(page).click();
await expect(page).toHaveURL(new RegExp(`/coach\\?day=${day}$`));
await expect(dayPanel(page)).toBeVisible();
});
test("1920: the conversations and the day stay open side by side", async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1920, height: 1000 });
await mockCoach(page);
await mockDay(page);
await page.goto("/coach?c=frame-bp&day=2026-06-01", {
waitUntil: "domcontentloaded",
});
await expect(
page.locator('[data-slot="coach-message-thread"]').getByText(ANSWER),
).toBeVisible({ timeout: 15_000 });
await expect(dayPanel(page)).toHaveAttribute("data-shell", "docked");
await expect(panel(page)).toHaveAttribute("data-state", "open");
await waitForWidth(panel(page), 288);
const list = (await panel(page).boundingBox())!;
const dayBox = (await dayPanel(page).boundingBox())!;
expect(list.x + list.width).toBeLessThanOrEqual(dayBox.x + 1);
await expectPanelFlush(page, 1920, 1000);
expect(
(await page.locator('[data-slot="coach-page-main"]').boundingBox())!
.width,
).toBeGreaterThanOrEqual(560);
await expectNewChatFab(page);
await expectNoSidewaysScroll(page);
await shot(page, testInfo, "coach-frame-1920-both");
// Folding one leaves the other alone.
await expand(page).click();
await expect(panel(page)).toHaveAttribute("data-state", "closed");
await expect(dayPanel(page)).toBeVisible();
await expand(page).click();
await expect(panel(page)).toHaveAttribute("data-state", "open");
await expect(dayPanel(page)).toBeVisible();
});
test("1280 fine pointer: compact header buttons and rows", async ({
page,
}) => {
@@ -552,18 +722,7 @@ test.describe("Coach page frame", () => {
await expect(page).toHaveURL(/\/coach\?c=frame-sleep$/);
await expect(rows.nth(1)).toHaveAttribute("aria-current", "true");
const fab = panel(page).locator('[data-slot="coach-panel-new-chat"]');
const fabBox = (await fab.boundingBox())!;
const asideBox = (await panel(page).boundingBox())!;
// 24 px in from the panel's corner from `md`, 16 px on a phone.
expect(asideBox.x + asideBox.width - (fabBox.x + fabBox.width)).toBeCloseTo(
24,
0,
);
expect(
asideBox.y + asideBox.height - (fabBox.y + fabBox.height),
).toBeCloseTo(24, 0);
await fab.click();
await fab(page).click();
await expect(page).toHaveURL(/\/coach$/);
await expect(
page.locator('[data-slot="coach-input-textarea"]'),
@@ -715,6 +874,7 @@ test.describe("Coach page frame", () => {
new RegExp(`(^|\\s)${theme}(\\s|$)`),
);
await expect(panel(page)).toHaveAttribute("data-state", "open");
await shot(page, testInfo, `coach-frame-1440-${theme}`);
const scan = async (selector: string, label: string) => {
const result = await new AxeBuilder({ page })
@@ -781,6 +941,13 @@ test.describe("Coach page frame", () => {
// Below 1280 px the panel waits to be asked.
await expect(panel(page)).toHaveCount(0);
await expect(toggle(page)).toHaveAttribute("aria-expanded", "false");
// New conversation sits above the composer, clear of its field.
await expectNewChatFab(page);
const fabBox = (await fab(page).boundingBox())!;
const composerBox = (await page
.locator('[data-slot="coach-page-composer"]')
.boundingBox())!;
expect(fabBox.y + fabBox.height).toBeLessThanOrEqual(composerBox.y);
await expectNoSidewaysScroll(page);
await shot(page, testInfo, "coach-frame-390-thread");
@@ -788,16 +955,27 @@ test.describe("Coach page frame", () => {
const sheet = page.getByRole("dialog", { name: "Conversations" });
await expect(sheet).toBeVisible();
await expect(toggle(page)).toHaveAttribute("aria-expanded", "true");
const fab = sheet.locator('[data-slot="coach-panel-new-chat"]');
// Measured once the sheet has slid in.
await expect
.poll(async () => {
const box = (await fab.boundingBox())!;
return Math.round(390 - (box.x + box.width));
})
.toBe(16);
expect((await fab.boundingBox())!.width).toBe(48);
await expect(fab.locator("svg.lucide-plus")).toHaveCount(1);
await expect(
sheet.locator('[data-slot="coach-panel-new-chat"]'),
).toHaveCount(0);
// The title keeps its whole word beside four header controls.
const title = sheet.locator(
'[data-slot="coach-conversations-panel-header"] h2',
);
expect(
await title.evaluate((el) => el.scrollWidth <= el.clientWidth),
"the sheet title is not truncated",
).toBe(true);
// Without a strip, the sheet keeps a clear close button and no fold.
await expect(
sheet.locator('[data-slot="coach-panel-close"]'),
).toBeVisible();
await expect(
sheet.locator('[data-slot="coach-panel-close"]'),
).toHaveAttribute("aria-label", "Close");
await expect(
sheet.locator('[data-slot="coach-panel-collapse"]'),
).toHaveCount(0);
await shot(page, testInfo, "coach-frame-390-panel");
await page.keyboard.press("Escape");
+220
View File
@@ -0,0 +1,220 @@
import type { Page, Route } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
/**
* A saved conversation opens at its last answer, and stays there after that
* answer's chart arrives.
*
* Before: on a wide screen the thread opened at the top (scroll 0), and on a
* phone it stopped about 70 px short of the end, because the answer's chart
* loaded after the jump. The conversation here is long enough to scroll on
* both widths, and the stored table under the last answer is served late on
* purpose, so the end moves after the thread first paints.
*/
const CONVERSATION_ID = "coach-open-at-end-e2e";
const LAST_ID = "coach-open-at-end-last";
const TABLE = {
ref: "r1",
source: {
tool: "get_metric_table",
domain: "pulse",
window: "last30days",
period: "current",
granularity: "day",
},
shape: "timeSeries",
titleKey: "coach.result.title.byDay",
title: "Pulse by day",
rowCount: 14,
chartKind: "line",
displayed: true,
columns: [
{
key: "day",
kind: "period",
labelKey: "coach.result.column.day",
label: "Day",
},
{
key: "pulse",
kind: "number",
labelKey: "coach.result.column.value",
label: "Pulse",
unit: "bpm",
decimals: 0,
},
],
rows: Array.from({ length: 14 }, (_, i) => [
new Date(Date.UTC(2026, 8, 10 + i)).toISOString().slice(0, 10),
60 + (i % 5),
]),
truncated: false,
chart: { kind: "line", x: "day", series: ["pulse"] },
};
const PARAGRAPH =
"Your resting pulse held steady across the fortnight, with the usual small dip after the longer nights and a slight rise on the two days you logged a cold.";
function messages() {
const out: Array<Record<string, unknown>> = [];
for (let i = 0; i < 8; i += 1) {
const at = `2026-09-24T09:${String(i * 2).padStart(2, "0")}:00.000Z`;
out.push({
id: `u-${i}`,
role: "user",
content: `Question ${i + 1}: how did my pulse look?`,
createdAt: at,
metricSource: null,
providerType: null,
promptVersion: null,
tokensUsed: null,
model: null,
});
const last = i === 7;
out.push({
id: last ? LAST_ID : `a-${i}`,
role: "assistant",
content: `${PARAGRAPH} ${PARAGRAPH}`,
createdAt: at,
metricSource: last
? {
windows: ["last30days"],
metrics: ["pulse"],
results: [
{
ref: TABLE.ref,
source: TABLE.source,
shape: TABLE.shape,
titleKey: TABLE.titleKey,
title: TABLE.title,
rowCount: TABLE.rowCount,
chartKind: TABLE.chartKind,
displayed: TABLE.displayed,
},
],
}
: null,
providerType: "mock",
promptVersion: "e2e",
tokensUsed: 42,
model: "mock",
});
}
return out;
}
function fulfilJson(route: Route, data: unknown) {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data, error: null }),
});
}
async function mockConversation(page: Page) {
await page.route(
/\/api\/insights\/chat(?:\/[^?]+)?(?:\?.*)?$/,
async (route) => {
const url = new URL(route.request().url());
if (
url.pathname ===
`/api/insights/chat/${CONVERSATION_ID}/messages/${LAST_ID}/results`
) {
// Late on purpose: the chart lands after the thread has painted.
await new Promise((resolve) => setTimeout(resolve, 900));
return fulfilJson(route, { results: [TABLE] });
}
if (url.pathname.startsWith(`/api/insights/chat/${CONVERSATION_ID}/`)) {
return fulfilJson(route, { results: [] });
}
if (url.pathname === `/api/insights/chat/${CONVERSATION_ID}`) {
return fulfilJson(route, {
id: CONVERSATION_ID,
title: "Pulse over two weeks",
createdAt: "2026-09-24T09:00:00.000Z",
updatedAt: "2026-09-24T09:14:00.000Z",
messageCount: 16,
fenced: false,
attachments: [],
documentTitle: null,
attachmentCount: 0,
summary: null,
messages: messages(),
});
}
return fulfilJson(route, { conversations: [], nextCursor: null });
},
);
await page.route("**/api/insights/coach/nudge-status*", (route) =>
fulfilJson(route, { nudgedAt: null, unread: false }),
);
await page.route("**/api/coach/about-me/questions*", (route) =>
fulfilJson(route, { questions: [] }),
);
}
/** Distance from the thread's current view to its end, in px. */
async function distanceFromEnd(page: Page): Promise<number> {
return page
.locator('[data-slot="coach-message-thread"]')
.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight);
}
test.describe("Coach: a saved conversation opens at its end", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({ page }) => {
await serveAiBlock(page, aiBlockAvailable());
await mockConversation(page);
});
for (const width of [390, 1440] as const) {
test(`at ${width} px it lands on the last answer and stays after its chart loads`, async ({
page,
}) => {
await page.setViewportSize({
width,
height: width === 390 ? 844 : 900,
});
await page.goto(`/coach?c=${CONVERSATION_ID}`, {
waitUntil: "domcontentloaded",
});
// The empty state carries the same `coach-message-thread` slot, so the
// thread being visible says nothing about the conversation having
// arrived; the height used to be read from the empty state on a slow
// run. Wait for the frame only a loaded thread renders, and for every
// stored answer in it.
await expect(
page.locator('[data-slot="coach-message-thread-frame"]'),
).toBeVisible({ timeout: 15_000 });
await expect(
page.locator('[data-slot="coach-assistant-turn"]'),
).toHaveCount(8, { timeout: 15_000 });
const thread = page.locator(
'[data-slot="coach-message-thread-frame"] [data-slot="coach-message-thread"]',
);
// The thread scrolls at all: otherwise "at the end" proves nothing.
await expect
.poll(() =>
thread.evaluate((el) => el.scrollHeight > el.clientHeight + 200),
)
.toBe(true);
const chart = page.locator(
'[data-slot="coach-result-chart"][data-ref="r1"]',
);
await expect(chart).toBeVisible({ timeout: 15_000 });
// The chart grew the thread after the first jump; the view followed.
await expect.poll(() => distanceFromEnd(page)).toBeLessThanOrEqual(2);
await expect(
page.locator(`[data-slot="coach-assistant-turn"]`).last(),
).toBeInViewport();
});
}
});
+275
View File
@@ -0,0 +1,275 @@
import AxeBuilder from "@axe-core/playwright";
import type { Page } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { expect, test } from "./setup/test";
/**
* v1.42 — the command palette.
*
* - Cmd/Ctrl+K, "sleep", Enter lands on the sleep page;
* - a settings card is a link with its anchor, and the page scrolls to it;
* - a module's entry is a switch: Enter sends the module write and the
* palette stays open (the write is answered by a route mock, so the
* shared account's modules never move);
* - the top bar carries a magnifier at every width, named "Search",
* with a tooltip that gives the shortcut; on a phone it opens a
* full-height sheet with the field focused;
* - the open palette passes axe;
* - a hit from the record search ("In your records", answered by a route
* mock so the shared account's records never move) lists after the local
* results, Enter on it opens its page, and a visit lands on the visits
* tab; on a phone the hit fits the width.
*
* Stable hooks only: `command-palette` (+ `data-variant`),
* `command-palette-input`, `command-palette-trigger`,
* `command-palette-tooltip`,
* options by `data-entry`.
*/
test.use({ storageState: STORAGE_STATE_PATH });
const palette = (page: Page) => page.locator('[data-slot="command-palette"]');
/** Answer `POST /api/search` with one visit hit, and count the requests. */
async function mockRecordSearch(page: Page): Promise<string[]> {
const queries: string[] = [];
await page.route("**/api/search", async (route) => {
// The query rides the JSON body, never the URL.
expect(route.request().method()).toBe("POST");
expect(route.request().url()).not.toContain("?");
const body = route.request().postDataJSON() as { q?: string } | null;
queries.push(body?.q ?? "");
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
groups: [
{
kind: "visit",
more: false,
hits: [
{
id: "visit:e2e-visit",
kind: "visit",
title: { text: "Arthroscopy", marks: [] },
context: [
{ text: "Shoulder (left)", marks: [[0, 8]] },
{ text: "A practitioner with a long name", marks: [] },
],
snippet: {
text: "Follow-up for the shoulder after the operation, physiotherapy twice a week",
marks: [[18, 26]],
},
day: "2026-03-02",
path: "/checkups?visit=e2e-visit",
source: null,
},
],
},
],
truncated: false,
},
error: null,
}),
});
});
return queries;
}
const input = (page: Page) =>
page.locator('[data-slot="command-palette-input"]');
async function shellReady(page: Page): Promise<void> {
await expect(page.locator("#main-content")).toBeVisible({ timeout: 20_000 });
await expect(page.locator('[data-slot="top-bar"]')).toBeVisible();
}
test.describe("command palette", () => {
test.beforeEach(async ({ context }, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"palette journeys run on the desktop project (the phone case resizes)",
);
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
await context.addCookies([
{ name: "healthlog-locale", value: "en", url: baseURL },
]);
});
test("Cmd/Ctrl+K, sleep, Enter opens the sleep page", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/");
await shellReady(page);
await page.keyboard.press("ControlOrMeta+k");
await expect(palette(page)).toHaveAttribute("data-variant", "dialog");
await expect(input(page)).toBeFocused();
await input(page).fill("sleep");
// The best hit is the highlighted option.
const active = await input(page).getAttribute("aria-activedescendant");
await expect(page.locator(`[id="${active}"]`)).toHaveAttribute(
"data-entry",
"insights:sleep",
);
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/insights\/sleep$/);
await expect(palette(page)).toHaveCount(0);
});
test("a settings card opens at its anchor", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/");
await shellReady(page);
await page.keyboard.press("ControlOrMeta+k");
await input(page).fill("passkeys");
await page.locator('[data-entry="settings:security#passkeys"]').click();
await expect(page).toHaveURL(/\/settings\/security#passkeys$/);
await expect(page.locator("#passkeys")).toBeInViewport({ timeout: 10_000 });
});
test("a module is a switch; Enter flips it and the palette stays", async ({
page,
}) => {
await page.setViewportSize({ width: 1440, height: 900 });
const writes: unknown[] = [];
await page.route("**/api/auth/me/modules", async (route) => {
if (route.request().method() !== "PATCH") return route.fallback();
writes.push(route.request().postDataJSON());
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
modules: { timeline: true },
updatedAt: new Date().toISOString(),
},
error: null,
}),
});
});
await page.goto("/");
await shellReady(page);
await page.keyboard.press("ControlOrMeta+k");
await input(page).fill("timeline");
const option = page.locator('[data-entry="module:timeline"]');
await expect(option).toHaveAttribute("aria-checked", "false");
await option.hover();
await page.keyboard.press("Enter");
await expect.poll(() => writes.length).toBe(1);
expect(writes[0]).toMatchObject({ timeline: true });
await expect(palette(page)).toBeVisible();
await expect(input(page)).toHaveValue("timeline");
});
test("a hit from the record opens its page", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
const queries = await mockRecordSearch(page);
// Start on the page the hit opens, on another tab: the link has to move
// the open page, not only a fresh one.
await page.goto("/checkups");
await shellReady(page);
await expect(
page.locator('[data-testid="checkups-tab-visits"]'),
).toHaveAttribute("aria-selected", "false");
await page.keyboard.press("ControlOrMeta+k");
await input(page).fill("shoulder");
const hit = page.locator('[data-entry="record:visit:e2e-visit"]');
await expect(hit).toBeVisible();
// Asked once the typing paused, not once per letter.
expect(queries).toEqual(["shoulder"]);
await expect(
page.locator('[data-group="records"] mark').first(),
).toHaveText("Shoulder");
const axe = await new AxeBuilder({ page })
.include('[data-slot="command-palette"]')
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(axe.violations).toEqual([]);
// The arrows reach it after the local options, and Enter opens it.
for (let i = 0; i < 40; i++) {
const active = await input(page).getAttribute("aria-activedescendant");
if (active && (await hit.getAttribute("id")) === active) break;
await page.keyboard.press("ArrowDown");
}
await expect(hit).toHaveAttribute("aria-selected", "true");
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/checkups\?visit=e2e-visit$/);
await expect(palette(page)).toHaveCount(0);
await expect(
page.locator('[data-testid="checkups-tab-visits"]'),
).toHaveAttribute("aria-selected", "true");
});
test("the top bar: a magnifier on a wide desktop, with the shortcut in its tooltip", async ({
page,
}) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/");
await shellReady(page);
const magnifier = page.locator('[data-slot="command-palette-trigger"]');
await expect(magnifier).toHaveCount(1);
await expect(magnifier).toBeVisible();
await expect(magnifier).toHaveAttribute("aria-label", "Search");
await expect(magnifier).toHaveAttribute(
"aria-keyshortcuts",
/^(Meta|Control)\+K$/,
);
// An icon, not a field: no visible words in the button.
expect((await magnifier.innerText()).trim()).toBe("");
await magnifier.hover();
await expect(
page.locator('[data-slot="command-palette-tooltip"]').first(),
).toHaveText(/^Search \((⌘K|Ctrl K)\)$/);
await magnifier.click();
await expect(palette(page)).toBeVisible();
await expect(input(page)).toBeFocused();
const axe = await new AxeBuilder({ page })
.include('[data-slot="command-palette"]')
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(axe.violations).toEqual([]);
await page.keyboard.press("Escape");
await expect(palette(page)).toHaveCount(0);
});
test("on a phone: the magnifier opens a full-height sheet", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/");
await shellReady(page);
const magnifier = page.locator('[data-slot="command-palette-trigger"]');
await expect(magnifier).toBeVisible();
await expect(magnifier).toHaveAttribute("aria-label", "Search");
await magnifier.click();
await expect(palette(page)).toHaveAttribute("data-variant", "sheet");
await expect(input(page)).toBeFocused();
const box = await palette(page).boundingBox();
expect(box?.height ?? 0).toBeGreaterThanOrEqual(844 - 1);
await input(page).fill("weight");
await expect(page.locator('[data-entry="insights:weight"]')).toBeVisible();
});
test("on a phone: a hit from the record fits the width", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await mockRecordSearch(page);
await page.goto("/");
await shellReady(page);
await page.locator('[data-slot="command-palette-trigger"]').click();
await input(page).fill("shoulder");
const hit = page.locator('[data-entry="record:visit:e2e-visit"]');
await expect(hit).toBeVisible();
const box = await hit.boundingBox();
expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(390);
const overflow = await page
.locator('[data-slot="command-palette-results"]')
.evaluate((el) => el.scrollWidth - el.clientWidth);
expect(overflow).toBeLessThanOrEqual(0);
await hit.click();
await expect(page).toHaveURL(/\/checkups\?visit=e2e-visit$/);
});
});
+330
View File
@@ -0,0 +1,330 @@
import type { Locator, Page, TestInfo } from "@playwright/test";
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
import { DAY_DOORS_ANALYTE } from "./setup/day-doors-fixture";
import { mockDay } from "./setup/day-mock";
import {
DAY_DOORS_STORAGE_STATE_PATH,
MOBILE_ROUTES_STORAGE_STATE_PATH,
} from "./setup/global-setup";
import { expect, test } from "./setup/test";
/**
* v1.42 — every chart drawn in days opens its days, the same way.
*
* The day view first reached six metric pages; the recovery page beside them
* offered no day at all. These journeys walk the surfaces that came after,
* each through the same doors: on a fine pointer a click on a point (or a
* calendar cell) opens the day beside the page; on a touch screen the first
* tap shows the value and the tooltip's "View the whole day" opens it as a
* bottom sheet, the tapped value on top. A date in a list opens in one tap.
* The dashed line (or outline) marks the open day, the row of dots and the
* caption sit under every chart, and the data table's dates are the
* keyboard way to the same days.
*
* - recovery (the page the beta reported), sleep, a lab marker, a chart
* with its comparison period on and a dashboard chart: line charts;
* - the mood calendar: a heatmap;
* - a metric's "all values" list: a date in a row.
*
* The day itself is a route mock in the contract's shape, as in
* `day-view.spec.ts`: these journeys pin the client's doors, not the day.
* Stable data attributes only.
*/
const panel = (page: Page) => page.locator('[data-slot="day-panel"]');
const isPhone = (testInfo: TestInfo) =>
testInfo.project.name === "chromium-mobile";
/** Open the day of the point at 80 % across a day-linked plot. */
async function openFromPlot(page: Page, plot: Locator, testInfo: TestInfo) {
await plot.scrollIntoViewIfNeeded();
await expect(plot).toHaveAttribute("data-day-links", "true");
const box = (await plot.boundingBox())!;
const x = box.x + box.width * 0.8;
const y = box.y + box.height / 2;
if (isPhone(testInfo)) {
await page.touchscreen.tap(x, y);
const open = page.locator('[data-slot="chart-tooltip-open-day"]:visible');
await expect(open).toBeVisible();
// A tap alone never opens a sheet: a point is a small target.
await expect(panel(page)).toHaveCount(0);
// The button is a calm, full 44 px row.
expect((await open.boundingBox())!.height).toBeGreaterThanOrEqual(44);
await open.tap();
await expect(panel(page)).toHaveAttribute("data-shell", "bottom");
} else {
await page.mouse.move(x, y);
await page.mouse.click(x, y);
await expect(panel(page)).toHaveAttribute("data-shell", "docked");
}
await expect(page).toHaveURL(/[?&]day=\d{4}-\d{2}-\d{2}/);
await expect(page.locator('[data-slot="day-view"]')).toBeVisible();
}
test.describe("every chart drawn in days opens its days", () => {
test.use({ storageState: DAY_DOORS_STORAGE_STATE_PATH });
test.beforeEach(async ({ page, context }, testInfo) => {
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
await context.addCookies([
{ name: "healthlog-locale", value: "en", url: baseURL },
]);
await mockDay(page, { index: "full" });
});
test("recovery: a point opens its day, and the open day is marked", async ({
page,
}, testInfo) => {
if (!isPhone(testInfo)) {
await page.setViewportSize({ width: 1440, height: 900 });
}
await page.goto("/insights/recovery");
const plot = page
.locator('[data-slot="chart-plot"][data-day-links="true"]')
.first();
await expect(plot).toBeVisible();
// Under the chart: the row of day dots, which draws the
// days the index names, one of them ringed as notable.
const rug = page.locator('[data-slot="day-rug"]').first();
await expect(rug.locator("[data-day]").first()).toBeAttached();
await expect(rug.locator('[data-notable="true"]')).toHaveCount(1);
await openFromPlot(page, plot, testInfo);
const day = await page
.locator('[data-slot="day-view"]')
.getAttribute("data-day");
// The value the person came from heads the day.
await expect(page.locator('[data-slot="day-focus"]')).toBeVisible();
// The open day's dot is the filled one.
await expect(rug.locator(`[data-day="${day}"]`)).toHaveAttribute(
"data-open",
"true",
);
});
test("recovery: the data table's dates are the keyboard way to a day", async ({
page,
}, testInfo) => {
test.skip(isPhone(testInfo), "keyboard path");
await page.goto("/insights/recovery");
const toggle = page
.locator('[data-slot="chart-data-table-toggle"]')
.first();
await toggle.scrollIntoViewIfNeeded();
await toggle.focus();
await page.keyboard.press("Enter");
const link = page
.locator('[data-slot="chart-data-table-region"] [data-slot="day-link"]')
.first();
await link.focus();
const day = await link.getAttribute("data-day");
await page.keyboard.press("Enter");
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
"data-day",
day!,
);
// Escape closes the day and gives the keyboard back to the date.
await page.keyboard.press("Escape");
await expect(panel(page)).toHaveCount(0);
});
test("sleep: a night's point opens its day", async ({ page }, testInfo) => {
await page.goto("/insights/sleep");
const plot = page
.locator('[data-slot="chart-plot"][data-day-links="true"]')
.first();
await expect(plot).toBeVisible();
await openFromPlot(page, plot, testInfo);
});
test("the mood calendar: a logged day's cell opens it", async ({
page,
}, testInfo) => {
await page.goto("/insights/mood");
// A logged, openable cell: it carries the pointer cursor.
const cell = page.locator("rect.cursor-pointer[data-day]").last();
await cell.scrollIntoViewIfNeeded();
await expect(cell).toBeVisible();
const day = await cell.getAttribute("data-day");
if (isPhone(testInfo)) {
await cell.tap();
const open = page.locator(
'[data-slot="heatmap-tooltip"] [data-slot="chart-tooltip-open-day"]',
);
await expect(open).toBeVisible();
await expect(panel(page)).toHaveCount(0);
await open.tap();
await expect(panel(page)).toHaveAttribute("data-shell", "bottom");
} else {
await cell.click();
}
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
"data-day",
day!,
);
// The open day's cell is outlined.
await expect(page.locator('[data-slot="heatmap-open-day"]')).toHaveCount(1);
});
test("a lab marker's reading opens the day it was taken", async ({
page,
}, testInfo) => {
await page.goto("/labs");
await page
.locator('[data-slot="lab-list-analyte"]')
.filter({ hasText: DAY_DOORS_ANALYTE })
.first()
.click();
const plot = page
.locator('[data-slot="chart-plot"][data-day-links="true"]')
.first();
await expect(plot).toBeVisible();
await openFromPlot(page, plot, testInfo);
// The reading on top, with the one before it beside it.
await expect(page.locator('[data-slot="day-focus"]')).toBeVisible();
});
test("a chart with its comparison period on still opens its days", async ({
page,
}, testInfo) => {
await page.goto("/insights/weight");
const plot = page
.locator('[data-slot="chart-plot"][data-day-links="true"]')
.first();
await expect(plot).toBeVisible();
await page
.locator('[data-slot="chart-overlay-controls-trigger"]')
.first()
.click();
await page
.locator('[data-slot="chart-overlay-comparison-lastMonth"]')
.click();
await page.keyboard.press("Escape");
// The overlay menu is closed before the chart is touched, so the tap
// lands on the chart and not on the menu's outside-dismiss.
await expect(
page.locator('[data-slot="chart-overlay-controls-content"]'),
).toHaveCount(0);
await expect(
page.locator('[data-slot="chart-compare-caption"]').first(),
).toBeAttached();
await openFromPlot(page, plot, testInfo);
await expect(page.locator('[data-slot="day-focus"]')).toBeVisible();
});
});
test.describe("the dashboard and the list of all values", () => {
test.use({ storageState: DAY_DOORS_STORAGE_STATE_PATH });
test.beforeEach(async ({ page, context }, testInfo) => {
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
await context.addCookies([
{ name: "healthlog-locale", value: "en", url: baseURL },
]);
await mockDay(page, { index: "full" });
});
test("a dashboard chart opens its day; the today hero offers none", async ({
page,
}, testInfo) => {
if (!isPhone(testInfo)) {
await page.setViewportSize({ width: 1440, height: 900 });
}
await page.goto("/");
await expect(
page.locator(
'[data-slot^="today-hero"] [data-slot="day-link"], [data-slot^="today-hero"] [data-day-links="true"]',
),
).toHaveCount(0);
const plot = page
.locator('[data-slot="chart-plot"][data-day-links="true"]')
.first();
await expect(plot).toBeVisible({ timeout: 20_000 });
await openFromPlot(page, plot, testInfo);
// Closing leaves the dashboard on today: a sheet from its close button,
// the docked day from its strip, the only control it has.
await page
.locator(
isPhone(testInfo)
? '[data-slot="day-close"]'
: '[data-slot="day-strip-toggle"]',
)
.click();
await expect(panel(page)).toHaveCount(0);
await expect(page).toHaveURL(/\/$/);
});
test("a date in a metric's list of all values opens its day", async ({
page,
}, testInfo) => {
await page.goto("/insights/values/WEIGHT");
const link = page.locator('[data-slot="day-link"]:visible').first();
await expect(link).toBeVisible();
const day = await link.getAttribute("data-day");
if (isPhone(testInfo)) await link.tap();
else await link.click();
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
"data-day",
day!,
);
});
test("the docked day, open or collapsed, moves the Coach button off it", async ({
page,
}, testInfo) => {
test.skip(isPhone(testInfo), "docked from 1280 px");
await page.setViewportSize({ width: 1440, height: 900 });
// The launcher follows the `coach` capability, which needs a provider
// the suite cannot reach, so the capability reads available here; this
// journey is about where the button sits, not what it opens.
await serveAiBlock(page, aiBlockAvailable());
await page.goto("/insights/weight");
const fab = page.locator('[data-slot="coach-fab"]');
await expect(fab).toBeVisible();
const plot = page
.locator('[data-slot="chart-plot"][data-day-links="true"]')
.first();
await openFromPlot(page, plot, testInfo);
const panelBox = (await panel(page).boundingBox())!;
// Wait out the 150 ms move, then the launcher sits left of the column.
await expect
.poll(async () => {
const b = (await fab.boundingBox())!;
return b.x + b.width;
})
.toBeLessThanOrEqual(panelBox.x);
// Folded, the day keeps its strip; the launcher stays clear of it.
await page.locator('[data-slot="day-strip-toggle"]').click();
await expect(panel(page)).toHaveCount(0);
const edge = (await page.locator('[data-slot="day-strip"]').boundingBox())!;
await expect
.poll(async () => {
const b = (await fab.boundingBox())!;
return b.x + b.width;
})
.toBeLessThanOrEqual(edge.x);
});
});
test.describe("the readiness link into a medication's edit form", () => {
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
test("?edit=1 opens the medication's edit form and leaves the URL", async ({
page,
}) => {
const res = await page.request.get("/api/medications");
const body = (await res.json()) as {
data: Array<{ id: string }> | { medications?: Array<{ id: string }> };
};
const list = Array.isArray(body.data)
? body.data
: (body.data.medications ?? []);
expect(list.length).toBeGreaterThan(0);
await page.goto(`/medications/${list[0]!.id}?edit=1`);
await expect(page.getByRole("dialog")).toBeVisible();
// A reload after closing the form must not open it again.
await expect(page).not.toHaveURL(/[?&]edit=1/);
});
});
+466
View File
@@ -0,0 +1,466 @@
import type { Page, TestInfo } from "@playwright/test";
import { mockDay } from "./setup/day-mock";
import { expect, test } from "./setup/test";
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture";
/**
* v1.42 — the day view and its doors.
*
* The record is the phone-sweep account `globalSetup` seeds: a week of blood
* pressure, two lab readings, mood and an episode. The day itself
* (`GET /api/day/{date}`, `GET /api/day/index`) is answered by a route mock in
* the contract's shape, so the journeys pin the client: which door opens which
* day, what the history does, and where no door is.
*
* - a chart point opens the day beside the chart (desktop);
* - on a touch screen the first tap shows the value and the tooltip offers
* the day, the second opens it as a bottom sheet;
* - a date in a list opens the day while the row keeps its own target;
* - `?day=` opens the layer on any page, Back and Escape close it, a date in
* the future is dropped;
* - stepping to the neighbouring days never adds history entries;
* - docked, the day folds to its strip, which runs the full height of the
* window right of the top bar, is the panel's only open and close
* control and brings the day back on any page; the search magnifier at
* the end of the top bar is never covered by it; a sheet closes for good
* from its close button;
* - the dashboard's today area offers no door; its charts below do.
*
* Stable data attributes only: `day-panel` (+ `data-shell`), `day-link`,
* the header's `day-date-button` and its `day-date-picker` (days keyed by
* `data-date-key`, dotted by `data-entries`, and `day-date-today`),
* `chart-plot[data-day-links]`, `chart-tooltip-open-day`, `day-prev` /
* `day-next` / `day-close`, and the docked day's strip `day-strip`
* (+ `data-day`, `data-state`) with its `day-strip-toggle`.
*/
const panel = (page: Page) => page.locator('[data-slot="day-panel"]');
async function shot(page: Page, testInfo: TestInfo, name: string) {
const path = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path });
await testInfo.attach(name, { path, contentType: "image/png" });
const dir = process.env.DAY_SHOTS_DIR;
if (dir) {
await page.screenshot({
path: `${dir}/${name}-${testInfo.project.name}.png`,
});
}
}
function isoDaysAgo(days: number): string {
const d = new Date(Date.now() - days * 86_400_000);
return d.toISOString().slice(0, 10);
}
test.describe("the day view", () => {
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
test.beforeEach(async ({ page, context }, testInfo) => {
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
await context.addCookies([
{ name: "healthlog-locale", value: "en", url: baseURL },
]);
await mockDay(page);
});
test("a chart point opens its day beside the chart; Back closes it", async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name !== "chromium-desktop", "desktop click");
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/insights/blood-pressure");
const plot = page.locator(
'[data-slot="chart-plot"][data-day-links="true"]',
);
await expect(plot).toBeVisible();
// The plot's right half holds the most recent points.
const box = (await plot.boundingBox())!;
await page.mouse.move(box.x + box.width * 0.8, box.y + box.height / 2);
await page.mouse.click(box.x + box.width * 0.8, box.y + box.height / 2);
await expect(panel(page)).toBeVisible();
await expect(panel(page)).toHaveAttribute("data-shell", "docked");
// A landmark beside the page, named by its date.
await expect(panel(page)).toHaveJSProperty("tagName", "ASIDE");
await expect(page).toHaveURL(/[?&]day=\d{4}-\d{2}-\d{2}/);
await expect(page.locator('[data-slot="day-focus"]')).toBeVisible();
// The chart beside it stays usable: the panel is not modal.
await expect(page.locator('[data-slot="sheet-overlay"]')).toHaveCount(0);
await page.goBack();
await expect(panel(page)).toHaveCount(0);
await expect(page).not.toHaveURL(/[?&]day=/);
});
test("a date in a list opens the day; the row keeps its own target", async ({
page,
}) => {
await page.goto("/labs");
await page
.locator('[data-slot="lab-list-analyte"]')
.filter({ hasText: MOBILE_ROUTES_ANALYTE })
.first()
.click();
// The marker's readings, each dated.
await page.locator('a[href$="/values"]').first().click();
const link = page.locator('[data-slot="day-link"]:visible').first();
await expect(link).toBeVisible();
const day = await link.getAttribute("data-day");
const before = page.url();
await link.click();
await expect(panel(page)).toBeVisible();
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
"data-day",
day!,
);
// Same page underneath: the click opened the day, not the reading.
expect(new URL(page.url()).pathname).toBe(new URL(before).pathname);
// The reading the person came from sits on top, with the one before it.
await expect(page.locator('[data-slot="day-focus"]')).toBeVisible();
});
test("?day= opens the layer on any page; Escape closes it", async ({
page,
}) => {
const day = isoDaysAgo(3);
await page.goto(`/mood?day=${day}`);
await expect(panel(page)).toBeVisible();
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
"data-day",
day,
);
await page.keyboard.press("Escape");
await expect(panel(page)).toHaveCount(0);
await expect(page).toHaveURL(/\/mood$/);
});
test("the day's scores sit below its values, each opening its own page", async ({
page,
}) => {
await page.goto(`/mood?day=${isoDaysAgo(3)}`);
await expect(panel(page)).toBeVisible();
const scores = page.locator('[data-slot="day-scores"]');
await expect(scores).toBeVisible();
const tiles = scores.locator('[data-slot="day-score"]');
await expect(tiles).toHaveCount(2);
await expect(
scores.locator('[data-slot="day-score"][data-score="healthScore"]'),
).toHaveAttribute("href", "/insights/health-score");
await expect(
scores.locator('[data-slot="day-score"][data-score="strain"]'),
).toHaveAttribute("href", "/insights/scores/strain");
// A score the day does not hold has no tile.
await expect(
scores.locator('[data-slot="day-score"][data-score="readiness"]'),
).toHaveCount(0);
// Below the values, above what happened.
const order = await page
.locator(
'[data-slot="day-values"], [data-slot="day-scores"], [data-slot="day-events"]',
)
.evaluateAll((nodes) =>
nodes.map((node) => node.getAttribute("data-slot")),
);
expect(order).toEqual(["day-values", "day-scores", "day-events"]);
});
test("the docked day folds beside its strip and comes back on another page", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"docked from 1280 px",
);
const day = isoDaysAgo(3);
await page.goto(`/mood?day=${day}`);
await expect(panel(page)).toHaveAttribute("data-shell", "docked");
// Docked, the header has no fold button: the strip is the toggle.
await expect(page.locator('[data-slot="day-close"]')).toHaveCount(0);
const rail = page.locator('[data-slot="day-strip"]');
const expand = page.locator('[data-slot="day-strip-toggle"]');
await expect(expand).toHaveAttribute("aria-expanded", "true");
await expand.click();
await expect(panel(page)).toHaveCount(0);
await expect(page).toHaveURL(/\/mood$/);
// The strip stays, holding the day, and focus waits on it.
await expect(rail).toHaveAttribute("data-day", day);
await expect(rail).toHaveAttribute("data-state", "closed");
await expect(expand).toBeFocused();
await expect(expand).toHaveAttribute("aria-expanded", "false");
await expect(expand).toHaveAttribute("aria-controls", "day-docked-panel");
expect(await expand.getAttribute("aria-label")).toMatch(/^Show /);
expect((await rail.boundingBox())!.width).toBeLessThanOrEqual(40);
// Another page: the edge is still there and brings the same day back,
// from the keyboard too.
await page.goto("/labs");
await expect(rail).toHaveAttribute("data-day", day);
await expand.focus();
await page.keyboard.press("Enter");
await expect(panel(page)).toHaveAttribute("data-shell", "docked");
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
"data-day",
day,
);
await expect(page).toHaveURL(new RegExp(`/labs\\?day=${day}$`));
await expect(panel(page).locator("h2").first()).toBeFocused();
// Open, the strip stays where it was and says so.
await expect(rail).toHaveAttribute("data-state", "open");
await expect(expand).toHaveAttribute("aria-expanded", "true");
});
test("the date opens a calendar; a picked day opens, Today goes to today", async ({
page,
}, testInfo) => {
const desktop = testInfo.project.name === "chromium-desktop";
// The days of the month that hold anything carry a dot.
await mockDay(page, { index: "full" });
const day = isoDaysAgo(5);
await page.goto(`/mood?day=${day}`);
await expect(panel(page)).toHaveAttribute(
"data-shell",
desktop ? "docked" : "bottom",
);
const date = panel(page).locator('[data-slot="day-date-button"]');
const picker = page.locator('[data-slot="day-date-picker"]');
// The date stays one line in the header, the arrows beside it.
await expect(date).toHaveAttribute("aria-expanded", "false");
await expect(panel(page).locator('[data-slot="day-prev"]')).toBeVisible();
await shot(page, testInfo, "day-date-closed");
await date.click();
await expect(picker).toBeVisible();
await expect(date).toHaveAttribute("aria-expanded", "true");
await expect(picker.locator(`[data-date-key="${day}"]`)).toHaveAttribute(
"data-selected-single",
"true",
);
await expect(picker.locator('[data-entries="true"]').first()).toBeVisible();
// Nothing past today can be picked.
const tomorrow = picker.locator(`[data-date-key="${isoDaysAgo(-2)}"]`);
if ((await tomorrow.count()) > 0) await expect(tomorrow).toBeDisabled();
await shot(page, testInfo, "day-date-open");
// Another day of the same month: the 1st, or the 2nd when the open day
// is the 1st itself.
const first = `${day.slice(0, 8)}01`;
const target = first === day ? isoDaysAgo(4) : first;
const start = await page.evaluate(() => window.history.length);
await picker.locator(`[data-date-key="${target}"]`).click();
await expect(picker).toHaveCount(0);
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
"data-day",
target,
);
await expect(page).toHaveURL(new RegExp(`[?&]day=${target}`));
// Like a step: no new history entry, and focus back on the date.
expect(await page.evaluate(() => window.history.length)).toBe(start);
await expect(date).toBeFocused();
// The keyboard: Enter opens, Escape closes the calendar only.
await page.keyboard.press("Enter");
await expect(picker).toBeVisible();
// The calendar has the keyboard before Escape is pressed.
await expect
.poll(() =>
page.evaluate(
() =>
document.activeElement?.closest(
'[role="dialog"]:not([data-slot="day-panel"])',
) != null,
),
)
.toBe(true);
// On a phone the calendar is a sheet over the day's own sheet. Focus
// arrives before Radix has handed it the Escape key: in between, the day
// underneath still holds it. The handover shows as the day's layer
// turning inert (`pointer-events: none`) once the calendar is the top
// layer; wait for that, not for a fixed time. The docked column is no
// dialog and has no such handover.
if (!desktop)
await expect
.poll(() =>
panel(page).evaluate(
(el) =>
new Promise<string>((resolve) =>
requestAnimationFrame(() =>
setTimeout(() => resolve(getComputedStyle(el).pointerEvents)),
),
),
),
)
.toBe("none");
await page.keyboard.press("Escape");
await expect(picker).toHaveCount(0);
await expect(panel(page)).toBeVisible();
await expect(date).toBeFocused();
// Today.
await page.keyboard.press("Space");
await expect(picker).toBeVisible();
await picker.locator('[data-slot="day-date-today"]').click();
await expect(picker).toHaveCount(0);
await expect(panel(page).locator('[data-slot="day-next"]')).toBeDisabled();
});
test("a sheet closes for good and leaves no strip behind", async ({
page,
}, testInfo) => {
const desktop = testInfo.project.name === "chromium-desktop";
// Below 1280 px the day is a sheet from the right; on a phone, from the
// bottom. Neither collapses.
if (desktop) await page.setViewportSize({ width: 1024, height: 800 });
const day = isoDaysAgo(3);
await page.goto(`/mood?day=${day}`);
await expect(panel(page)).toHaveAttribute(
"data-shell",
desktop ? "sheet" : "bottom",
);
await expect(page.locator('[data-slot="day-close"]')).toHaveAttribute(
"aria-label",
"Close day",
);
await page.locator('[data-slot="day-close"]').click();
await expect(panel(page)).toHaveCount(0);
await expect(page).toHaveURL(/\/mood$/);
await expect(page.locator('[data-slot="day-strip"]')).toHaveCount(0);
});
test("the strip runs the full height; the search magnifier stays clear of it", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"docked from 1280 px",
);
const day = isoDaysAgo(3);
const strip = page.locator('[data-slot="day-strip"]');
const search = page.locator('[data-slot="command-palette-trigger"]');
const topBar = page.locator('[data-slot="top-bar"]');
for (const width of [1280, 1440, 1920]) {
await page.setViewportSize({ width, height: 900 });
for (const url of ["/mood", `/mood?day=${day}`]) {
await page.goto(url);
await expect(strip).toBeVisible();
if (url.includes("day=")) {
await expect(panel(page)).toHaveAttribute("data-shell", "docked");
// The open panel runs the full height beside its strip.
await expect
.poll(async () => (await panel(page).boundingBox())?.width)
.toBeGreaterThan(400);
const p = (await panel(page).boundingBox())!;
expect(p.y).toBe(0);
expect(Math.round(p.y + p.height)).toBe(900);
}
const s = (await strip.boundingBox())!;
// Top edge at the very top of the window, down to the bottom.
expect(s.y).toBe(0);
expect(Math.round(s.height)).toBe(900);
expect(Math.round(s.x + s.width)).toBe(width);
// The top bar ends left of the strips, so the magnifier sits fully
// inside it, in front of them.
const bar = (await topBar.boundingBox())!;
expect(bar.x + bar.width).toBeLessThanOrEqual(s.x + 0.5);
await expect(search).toBeVisible();
const m = (await search.boundingBox())!;
expect(m.x).toBeGreaterThanOrEqual(0);
expect(m.y).toBeGreaterThanOrEqual(0);
expect(m.x + m.width).toBeLessThanOrEqual(s.x);
expect(m.y + m.height).toBeLessThanOrEqual(bar.y + bar.height);
// Nothing paints over its centre: the hit test lands on it.
const hit = await page.evaluate(
([x, y]) =>
document
.elementFromPoint(x, y)
?.closest('[data-slot="command-palette-trigger"]') != null,
[m.x + m.width / 2, m.y + m.height / 2],
);
expect(hit).toBe(true);
// No horizontal scroll anywhere on the page.
const overflow = await page.evaluate(
() =>
document.documentElement.scrollWidth -
document.documentElement.clientWidth,
);
expect(overflow).toBeLessThanOrEqual(0);
}
}
});
test("a future or malformed ?day= is dropped without a word", async ({
page,
}) => {
await page.goto("/mood?day=2999-01-01");
await expect(page).toHaveURL(/\/mood$/);
await expect(panel(page)).toHaveCount(0);
await page.goto("/mood?day=2026-02-30");
await expect(page).toHaveURL(/\/mood$/);
await expect(panel(page)).toHaveCount(0);
});
test("stepping between days never adds a history entry", async ({ page }) => {
await page.goto("/mood");
await page.locator('[data-slot="day-link"]:visible').first().click();
await expect(panel(page)).toBeVisible();
const start = await page.evaluate(() => window.history.length);
const first = await page
.locator('[data-slot="day-view"]')
.getAttribute("data-day");
for (let i = 0; i < 3; i += 1) {
await page.locator('[data-slot="day-prev"]').click();
}
await expect(page.locator('[data-slot="day-view"]')).not.toHaveAttribute(
"data-day",
first!,
);
expect(await page.evaluate(() => window.history.length)).toBe(start);
// One Back leaves the layer, however far it stepped.
await page.goBack();
await expect(panel(page)).toHaveCount(0);
await expect(page).toHaveURL(/\/mood$/);
});
test("on a phone the first tap shows the value, the second opens the day", async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name !== "chromium-mobile", "touch only");
await page.goto("/insights/blood-pressure");
const plot = page.locator(
'[data-slot="chart-plot"][data-day-links="true"]',
);
await plot.scrollIntoViewIfNeeded();
await expect(plot).toBeVisible();
const box = (await plot.boundingBox())!;
await page.touchscreen.tap(box.x + box.width * 0.8, box.y + box.height / 2);
const open = page.locator('[data-slot="chart-tooltip-open-day"]');
await expect(open).toBeVisible();
// A tap alone never opens a sheet.
await expect(panel(page)).toHaveCount(0);
await open.tap();
await expect(panel(page)).toBeVisible();
await expect(panel(page)).toHaveAttribute("data-shell", "bottom");
// The tapped value heads the day.
await expect(page.locator('[data-slot="day-focus"]')).toBeVisible();
});
test("the dashboard's today area offers no day door", async ({ page }) => {
await page.goto("/");
await expect(
page.locator('[data-slot="main-content-wrapper"]'),
).toBeVisible();
// The hero is today: nothing in it opens another day. (The charts
// further down do; `day-doors.spec.ts` walks one.)
await expect(
page.locator('[data-slot^="today-hero"]').first(),
).toBeVisible();
await expect(
page.locator(
'[data-slot^="today-hero"] [data-slot="day-link"], [data-slot^="today-hero"] [data-day-links="true"]',
),
).toHaveCount(0);
});
});
+8 -1
View File
@@ -221,7 +221,14 @@ test.describe("delegated writes", () => {
// reads "Add") and then for a `value` input (the form opens on blood
// pressure, which has three). Both were wrong from the day they were
// written and nobody found out, because the whole file was skipping.
await page.locator('[data-slot="measurement-add"]').click();
// The owner's list may be empty, in which case its empty state carries
// the add action and the header drops its own.
await page
.locator(
'[data-slot="measurement-add"], [data-slot="measurement-add-first"]',
)
.first()
.click();
await page.locator("#sys").fill("124");
await page.locator("#dia").fill("78");
await page.getByRole("button", { name: /^save$/i }).click();
+6 -4
View File
@@ -70,13 +70,15 @@ const DIALOGS: DialogCase[] = [
{
name: "measurements add",
path: "/measurements",
trigger: /^add$/i,
// While a list is empty the header drops its add button and the empty
// state carries the action, so either one opens the dialog.
trigger: /^(add|add your first measurement)$/i,
ready: '[data-slot="date-time-field"]',
},
{
name: "mood add",
path: "/mood",
trigger: /^add$/i,
trigger: /^(add|log your first mood)$/i,
// The mood sheet opens on the five-face quick check; the timestamp row
// (and everything else) appears once a face is picked. Measuring the
// quick check alone would measure a sheet that has almost nothing in it.
@@ -92,7 +94,7 @@ const DIALOGS: DialogCase[] = [
{
name: "labs add",
path: "/labs",
trigger: /^add$/i,
trigger: /^(add|add your first result)$/i,
ready: '[data-slot="date-time-field"]',
},
// The labs sheet's footer holds three buttons (Cancel, Save & add another,
@@ -101,7 +103,7 @@ const DIALOGS: DialogCase[] = [
{
name: "labs add (German)",
path: "/labs",
trigger: /^hinzufügen$/i,
trigger: /^(hinzufügen|ersten wert erfassen)$/i,
ready: '[data-slot="date-time-field"]',
locale: "de",
},
+1 -1
View File
@@ -427,7 +427,7 @@ test.describe("document vault — AI assist + content search", () => {
// The PDF is refused locally (tesseract reads images only) — the error
// shows and the server suggest route is never called.
await expect(
sheet.getByText("Local OCR reads images only — not this file."),
sheet.getByText("Local OCR reads images only, not this file."),
).toBeVisible();
await page.waitForTimeout(300);
expect(suggestCalls).toBe(0);
+1 -1
View File
@@ -89,7 +89,7 @@ test.describe("document vault — procedure filter", () => {
const knee = menu.getByRole("menuitemcheckbox", {
name: /Knee arthroscopy/,
});
await expect(knee).toContainText("Knee · Left");
await expect(knee).toContainText("Knee (Left)");
await expect(
menu.getByRole("menuitemcheckbox", { name: /Appendectomy/ }),
).toBeVisible();
+97 -1
View File
@@ -151,6 +151,61 @@ test.describe("document vault", () => {
await ensureVaultFixture();
});
// ── v1.42 — the archive as a month grid of preview tiles ──────────────
for (const [width, columns] of [
[390, 2],
[1440, 4],
] as const) {
test(`the archive is a month grid of preview tiles, ${columns} across at ${width} px`, async ({
page,
}) => {
await page.setViewportSize({ width, height: width === 390 ? 844 : 900 });
await page.goto("/documents");
const firstRow = page.locator('[data-slot="document-month-row"]').first();
await expect(firstRow).toBeVisible({ timeout: 15_000 });
// The months run side by side by default: a month is marked above
// its first document instead of taking a heading row of its own.
await expect(
page.locator('[data-slot="document-timeline"]'),
).toHaveAttribute("data-arrangement", "flow");
await expect(
page.locator('[data-slot="document-flow-month"]').first(),
).toBeVisible();
// Tiles with the preview on top, and at most `columns` side by side:
// the fullest mounted row holds exactly that many, on one line.
const tiles = page.locator(
'[data-slot="document-month-row"] [data-slot="document-card"]',
);
await expect(tiles.first()).toHaveAttribute("data-variant", "tile");
await expect(
tiles.first().locator('[data-slot="document-preview"]'),
).toBeVisible();
const rows = await page
.locator('[data-slot="document-month-row"]')
.evaluateAll((els) =>
els.map((row) =>
Array.from(row.querySelectorAll('[data-slot="document-card"]')).map(
(el) => Math.round(el.getBoundingClientRect().top),
),
),
);
const fullest = rows.reduce((a, b) => (b.length > a.length ? b : a), []);
expect(fullest.length).toBe(columns);
expect(new Set(fullest).size).toBe(1);
// Nothing on the page scrolls sideways.
const overflow = await page.evaluate(
() =>
document.documentElement.scrollWidth -
document.documentElement.clientWidth,
);
expect(overflow).toBeLessThanOrEqual(0);
});
}
// ── §1A — the 30-second doctor flow, three independent routes ─────────
test("doctor flow A: the type filter reaches the MRT report", async ({
@@ -177,6 +232,45 @@ test.describe("document vault", () => {
await expect(sheet.locator("iframe")).toBeVisible({ timeout: 5_000 });
});
test("a PDF opens in a new browser tab from the fetched bytes", async ({
page,
}) => {
// Headless Chromium has no PDF viewer and turns a PDF tab into a
// download, so the tab itself is not observable here. Record what the
// control hands to `window.open` instead, and read the bytes behind it.
await page.addInitScript(() => {
const calls: unknown[][] = [];
(window as unknown as { __opened: unknown[][] }).__opened = calls;
window.open = (...args: unknown[]) => {
calls.push(args);
return null;
};
});
await page.goto(`/documents?episode=${KNIE_EPISODE_ID}&view=compact&q=MRT`);
await openButton(page, "MRT Knie").click();
const sheet = page.getByRole("dialog", { name: "MRT Knie" });
await expect(sheet.locator("iframe")).toBeVisible({ timeout: 5_000 });
const open = sheet.locator('[data-slot="document-open-in-tab"]');
// Enabled once the preview's bytes are in hand.
await expect(open).toBeEnabled();
if (process.env.E2E_SHOT_DIR) {
await page.screenshot({
path: `${process.env.E2E_SHOT_DIR}/open-in-tab-${test.info().project.name}.png`,
});
}
await open.click();
const opened = await page.evaluate(
() => (window as unknown as { __opened: string[][] }).__opened,
);
// The object URL the preview already shows, not the serve route: a bare
// navigation would carry no record-session assertion.
const previewSrc = await sheet.locator("iframe").getAttribute("src");
expect(previewSrc).toMatch(/^blob:/);
expect(opened).toEqual([[previewSrc, "_blank", "noopener"]]);
// The sheet stays where it was.
await expect(sheet).toBeVisible();
});
test("doctor flow B: the condition filter reaches the MRT report", async ({
page,
}) => {
@@ -413,6 +507,8 @@ test.describe("document vault", () => {
const sheet = page.getByRole("dialog", { name: `${title}.pdf` });
await expect(sheet).toBeVisible();
await sheet.getByRole("button", { name: "Delete" }).click();
// v1.42 — the sheet's Delete asks first, like every vault delete.
await page.locator('[data-slot="document-detail-delete-confirm"]').click();
// Undo toast → restore → the card returns.
//
@@ -714,7 +810,7 @@ test.describe("document vault", () => {
await uploadViaPicker(page, `${marker}-copy.pdf`, bytes);
await expect(
page.getByText("Already stored — highlighting the existing document."),
page.getByText("Already stored. The existing document is highlighted."),
).toBeVisible();
// One row, not two — the copy never landed.
await expect(openButton(page, `${marker}-copy.pdf`)).not.toBeVisible();
+310
View File
@@ -0,0 +1,310 @@
import AxeBuilder from "@axe-core/playwright";
import type { Page } from "@playwright/test";
import { mockDay } from "./setup/day-mock";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { expect, test } from "./setup/test";
import { mockTimeline } from "./utils/mock-timeline";
/**
* v1.42 — the keyboard shortcuts.
*
* - `g t` opens the timeline while its module is on, and does nothing while
* it is off;
* - keys typed into a field stay in the field;
* - `?` opens the list: a titled dialog that keeps focus inside and closes
* on Escape; the account menu opens the same list;
* - `n` opens the add menu;
* - `[` and `]` step the open day, never past today;
* - `g p` opens and closes the day like a click on its strip (focus into
* the panel, back to the strip); below the docking width it opens the
* sheet on the last day and closes it again.
*
* The module map is the account's real `/api/auth/me` with only `timeline`
* rewritten, so the switch is never moved on a shared account. Desktop only:
* a phone has no keyboard to press these on.
*/
test.use({ storageState: STORAGE_STATE_PATH });
const TODAY = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date());
function shiftDay(key: string, delta: number): string {
const d = new Date(`${key}T12:00:00Z`);
d.setUTCDate(d.getUTCDate() + delta);
return d.toISOString().slice(0, 10);
}
async function withTimelineModule(page: Page, on: boolean): Promise<void> {
await page.route("**/api/auth/me", async (route) => {
const res = await route.fetch();
const json = await res.json().catch(() => null);
if (!json?.data?.modules) return route.fulfill({ response: res });
json.data.modules.timeline = on;
return route.fulfill({
response: res,
body: JSON.stringify(json),
headers: { ...res.headers(), "content-type": "application/json" },
});
});
}
/** The shell is up and its listener registered. */
async function shellReady(page: Page): Promise<void> {
await expect(page.locator("#main-content")).toBeVisible({ timeout: 20_000 });
await expect(page.locator('[data-slot="top-bar"]')).toBeVisible();
}
const helpDialog = (page: Page) =>
page.getByRole("dialog", { name: "Keyboard shortcuts" });
test.describe("keyboard shortcuts", () => {
test.beforeEach(async ({ context }, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"keyboard journeys run on the desktop project",
);
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
await context.addCookies([
{ name: "healthlog-locale", value: "en", url: baseURL },
]);
});
test("g t opens the timeline while its module is on", async ({ page }) => {
await withTimelineModule(page, true);
await mockTimeline(page, "ready", TODAY);
await page.goto("/");
await shellReady(page);
await page.keyboard.press("g");
await page.keyboard.press("t");
await expect(page).toHaveURL(/\/timeline$/);
});
test("g t does nothing while the timeline module is off", async ({
page,
}) => {
await withTimelineModule(page, false);
// Every client navigation, in order, so "nothing happened" is a fact
// rather than a wait.
await page.addInitScript(() => {
const log: string[] = [];
(window as unknown as { __pushed: string[] }).__pushed = log;
const push = history.pushState.bind(history);
history.pushState = (state, unused, url) => {
if (url != null) log.push(String(url));
push(state, unused, url);
};
});
await page.goto("/insights");
await shellReady(page);
await page.keyboard.press("g");
await page.keyboard.press("t");
// The positive control: the same reader opens a page that is on.
await page.keyboard.press("g");
await page.keyboard.press("d");
await expect(page).toHaveURL(/\/$/);
const pushed = await page.evaluate(
() => (window as unknown as { __pushed: string[] }).__pushed,
);
expect(pushed.some((url) => url.includes("/timeline"))).toBe(false);
});
test("keys typed into a field stay in the field", async ({ page }) => {
await withTimelineModule(page, true);
await page.goto("/");
const main = page.locator("#main-content");
await expect(main).toBeVisible({ timeout: 20_000 });
// A plain field in the page: the rule is about the focused element, not
// about which page carries it.
await page.evaluate(() => {
const field = document.createElement("input");
field.setAttribute("data-testid", "probe-field");
field.setAttribute("aria-label", "Probe");
document.getElementById("main-content")?.prepend(field);
});
const field = page.getByTestId("probe-field");
await expect(field).toBeVisible();
await field.focus();
await page.keyboard.type("gt n ? [");
await expect(field).toHaveValue("gt n ? [");
await expect(page).toHaveURL(/\/$/);
await expect(helpDialog(page)).toHaveCount(0);
await expect(page.getByTestId("capture-picker-options")).toHaveCount(0);
});
test("? opens a titled list that keeps focus and closes on Escape", async ({
page,
}) => {
await page.goto("/");
await shellReady(page);
await page.keyboard.press("Shift+?");
const dialog = helpDialog(page);
await expect(dialog).toBeVisible();
await expect(
dialog.locator('[data-slot="keyboard-shortcuts"] [data-group]'),
).toHaveCount(3);
await expect(dialog.locator('[data-shortcut="go-d"] kbd')).toHaveText([
"g",
"d",
]);
await expect(
dialog.locator('[data-group="day"] [data-shortcut="day-toggle"] kbd'),
).toHaveText(["g", "p"]);
// Focus trap: Tab and Shift+Tab never leave the dialog.
for (const step of ["Tab", "Tab", "Tab", "Shift+Tab", "Shift+Tab"]) {
await page.keyboard.press(step);
expect(
await dialog.evaluate((el) => el.contains(document.activeElement)),
).toBe(true);
}
// While the list is open, the page's shortcuts are off.
await page.keyboard.press("g");
await page.keyboard.press("i");
await expect(page).toHaveURL(/\/$/);
const axe = await new AxeBuilder({ page })
.include('[role="dialog"]')
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(axe.violations).toEqual([]);
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
});
test("the account menu opens the same list", async ({ page }) => {
await page.goto("/");
await shellReady(page);
await page
.getByRole("button", { name: "User menu" })
.locator("visible=true")
.first()
.click();
await page.locator('[data-slot="open-keyboard-shortcuts"]').click();
await expect(helpDialog(page)).toBeVisible();
});
test("n opens the add menu", async ({ page }) => {
await page.goto("/insights");
await shellReady(page);
await page.keyboard.press("n");
await expect(page.getByTestId("capture-picker-options")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("capture-picker-options")).toHaveCount(0);
});
test("[ and ] step the open day, never past today", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockDay(page);
const yesterday = shiftDay(TODAY, -1);
await page.goto(`/insights?day=${yesterday}`);
await shellReady(page);
const panel = page.locator('[data-slot="day-panel"]');
await expect(panel).toHaveAttribute("data-shell", "docked");
// Focus on the page, not in the panel: docked, the keys still reach it.
await page.locator("#main-content").focus();
await page.keyboard.press("[");
await expect(page).toHaveURL(new RegExp(`[?&]day=${shiftDay(TODAY, -2)}`));
await page.keyboard.press("]");
await page.keyboard.press("]");
await expect(page).toHaveURL(new RegExp(`[?&]day=${TODAY}`));
await page.keyboard.press("]");
await expect(page).toHaveURL(new RegExp(`[?&]day=${TODAY}`));
});
test("g p opens and closes the docked day, focus like its strip", async ({
page,
}) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockDay(page);
await page.goto("/insights");
await shellReady(page);
const panel = page.locator('[data-slot="day-panel"]');
const strip = page.locator('[data-slot="day-strip-toggle"]');
await expect(strip).toHaveAttribute("aria-expanded", "false");
await expect(panel).toHaveCount(0);
await page.keyboard.press("g");
await page.keyboard.press("p");
await expect(panel).toHaveAttribute("data-shell", "docked");
await expect(page).toHaveURL(new RegExp(`[?&]day=${TODAY}`));
await expect(strip).toHaveAttribute("aria-expanded", "true");
// Into the panel, on its heading, the way the strip opens it.
await expect(panel.locator("h2").first()).toBeFocused();
await page.keyboard.press("g");
await page.keyboard.press("p");
await expect(panel).toHaveCount(0);
await expect(page).toHaveURL(/\/insights$/);
await expect(strip).toHaveAttribute("aria-expanded", "false");
// Back on the strip, which brings the day back.
await expect(strip).toBeFocused();
});
test("below the docking width g p opens the sheet on the last day and closes it", async ({
page,
}) => {
await page.setViewportSize({ width: 1024, height: 800 });
await mockDay(page);
const day = shiftDay(TODAY, -3);
// Open and close a day once, so it is the one remembered.
await page.goto(`/insights?day=${day}`);
await shellReady(page);
const panel = page.locator('[data-slot="day-panel"]');
await expect(panel).toHaveAttribute("data-shell", "sheet");
await page.keyboard.press("Escape");
await expect(panel).toHaveCount(0);
await page.locator("#main-content").focus();
await page.keyboard.press("g");
await page.keyboard.press("p");
await expect(panel).toHaveAttribute("data-shell", "sheet");
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
"data-day",
day,
);
// Over the sheet, the same keys close it.
await page.keyboard.press("g");
await page.keyboard.press("p");
await expect(panel).toHaveCount(0);
await expect(page).toHaveURL(/\/insights$/);
});
test("g p stays out of a field and out of another dialog", async ({
page,
}) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockDay(page);
await page.goto("/insights");
await shellReady(page);
const panel = page.locator('[data-slot="day-panel"]');
await expect(page.locator('[data-slot="day-strip"]')).toBeVisible();
await page.evaluate(() => {
const field = document.createElement("input");
field.setAttribute("data-testid", "probe-field");
field.setAttribute("aria-label", "Probe");
document.getElementById("main-content")?.prepend(field);
});
await page.getByTestId("probe-field").focus();
await page.keyboard.type("gp");
await expect(page.getByTestId("probe-field")).toHaveValue("gp");
await expect(panel).toHaveCount(0);
await page.locator("#main-content").focus();
await page.keyboard.press("Shift+?");
await expect(helpDialog(page)).toBeVisible();
await page.keyboard.press("g");
await page.keyboard.press("p");
await expect(panel).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(helpDialog(page)).toHaveCount(0);
});
});
+7 -1
View File
@@ -135,7 +135,13 @@ test.describe("add measurement flow", () => {
// v1.5 phase-5: the menu items now have distinct labels — the
// measurement entry says "Measurement" / "Messung" instead of "Add",
// so we can target it directly.
await page.getByRole("menuitem", { name: /measurement|messung/i }).click();
// The desktop rail opens a dropdown, the phone shell the capture sheet
// the bottom nav's plus opens; the measurement entry is in either.
await page
.getByRole("menuitem", { name: /measurement|messung/i })
.or(page.getByRole("button", { name: /^(measurement|messung)\b/i }))
.first()
.click();
// Form should be visible inside the dialog. Switch type to WEIGHT
// (combo-box defaults to BLOOD_PRESSURE).
+19 -2
View File
@@ -30,7 +30,20 @@ interface StubbedMedication {
}
export function mockMedicationsApi(page: Page, stubbed: StubbedMedication) {
const capture: { postBody: unknown } = { postBody: null };
// A body that is not JSON must fail the spec where it reads `postBody`,
// with the parse error, not throw inside the un-awaited route callback.
const capture = {
rawPostBody: null as unknown,
parseError: null as string | null,
get postBody(): unknown {
if (this.parseError !== null) {
throw new Error(
`POST /api/medications sent a body that is not JSON: ${this.parseError}`,
);
}
return this.rawPostBody;
},
};
const listEntry = {
id: stubbed.id,
@@ -68,7 +81,11 @@ export function mockMedicationsApi(page: Page, stubbed: StubbedMedication) {
return;
}
if (req.method() === "POST") {
capture.postBody = JSON.parse(req.postData() ?? "null");
try {
capture.rawPostBody = JSON.parse(req.postData() ?? "null");
} catch (err) {
capture.parseError = err instanceof Error ? err.message : String(err);
}
await route.fulfill({
status: 201,
contentType: "application/json",
+2
View File
@@ -48,6 +48,8 @@ const NOT_SIGNED_IN: Record<string, string> = {
"/invite/[token]":
"an invitation landing page for somebody without an account",
"/c/[token]": "the clinician share-link page, opened without a session",
"/claim/[token]":
"the managed-profile handover link, opened by the person without a session (covered by v142-managed-profile-handover.spec.ts)",
"/confirm-access": "the re-proof page, reached only from a stale session",
"/enroll-mfa": "the forced second-factor enrolment of a policy-bound account",
"/onboarding": "the first-run setup flow; covered by setup-flow-*.spec.ts",
+3 -1
View File
@@ -32,7 +32,9 @@ test.describe("mood timestamp capture", () => {
});
await page.goto("/mood", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "Add", exact: true }).click();
// The list is mocked empty, so its empty state carries the add action
// (the header drops its own while there is nothing to list).
await page.getByRole("button", { name: "Log your first mood" }).click();
await page.getByRole("radio", { name: "Amazing" }).click();
const details = page.getByRole("button", { name: "Note & details" });
+17 -5
View File
@@ -141,7 +141,10 @@ test.describe("v1.4.33 F2 — onboarding tour passes clicks through to the page"
// so a naive "dim everything outside the spotlight rect" overlay
// covers it. The fix makes the entire tour layer pointer-events:
// none — every dim panel and the spotlight ring are visual only.
const quickAddButton = page.locator('[data-tour-id="dashboard-quick-add"]');
// One add button per shell carries the tour id; the other is hidden.
const quickAddButton = page
.locator('[data-tour-id="dashboard-quick-add"]')
.filter({ visible: true });
await expect(quickAddButton).toBeVisible();
// Forensic guard — at the quick-add button's centre, every element
@@ -153,9 +156,11 @@ test.describe("v1.4.33 F2 — onboarding tour passes clicks through to the page"
// the stack is blocking, instead of just observing the symptom
// ("button click did not toggle aria-expanded").
const tourBlockers = await page.evaluate(() => {
const btn = document.querySelector<HTMLElement>(
'[data-tour-id="dashboard-quick-add"]',
);
const btn = [
...document.querySelectorAll<HTMLElement>(
'[data-tour-id="dashboard-quick-add"]',
),
].find((el) => el.getClientRects().length > 0);
if (!btn) return ["no-button"];
const rect = btn.getBoundingClientRect();
const x = rect.left + rect.width / 2;
@@ -192,7 +197,14 @@ test.describe("v1.4.33 F2 — onboarding tour passes clicks through to the page"
await quickAddButton.click();
await expect(quickAddButton).toHaveAttribute("aria-expanded", "true");
await expect(page.getByRole("menuitem").first()).toBeVisible();
// The desktop rail opens a dropdown; the phone shell opens the capture
// sheet the bottom nav's plus opens. Either proves the click landed.
await expect(
page
.getByRole("menuitem")
.or(page.locator('[role="dialog"][data-state="open"]'))
.first(),
).toBeVisible();
// The tour stays mounted — the click went to the underlying button,
// not anything in the tour layer.
+239
View File
@@ -0,0 +1,239 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* The installed-app behaviours a phone depends on, measured in the Pixel 5
* project (a touch screen: `pointer: coarse`).
*
* 1. The "new version" hint stands above the bottom bar instead of on it,
* and its Reload action reloads the page.
* 2. With the on-screen keyboard open, a bottom sheet stands on the
* keyboard (its Save button stays visible) and the bottom bar steps
* aside. Playwright has no on-screen keyboard, so the visual viewport is
* replaced by a stand-in whose height the test shrinks the way a
* keyboard does; what is asserted is the page's answer to that signal.
* 3. No form field a phone can focus is set below 16 px, in portrait or
* held sideways, because iOS Safari zooms the page into such a field.
* 4. Held sideways, the sign-in card is not centred out of reach.
*
* The account is the populated mobile-sweep account; nothing here writes.
*/
const VISUAL_VIEWPORT_STAND_IN = () => {
const stand = new EventTarget() as EventTarget & {
height: number;
width: number;
offsetTop: number;
offsetLeft: number;
pageTop: number;
pageLeft: number;
scale: number;
};
stand.height = window.innerHeight;
stand.width = window.innerWidth;
stand.offsetTop = 0;
stand.offsetLeft = 0;
stand.pageTop = 0;
stand.pageLeft = 0;
stand.scale = 1;
Object.defineProperty(window, "visualViewport", {
configurable: true,
get: () => stand,
});
(window as unknown as { __viewport: typeof stand }).__viewport = stand;
};
async function setKeyboard(page: Page, covered: number) {
await page.evaluate((px) => {
const stand = (
window as unknown as {
__viewport: EventTarget & { height: number };
}
).__viewport;
stand.height = window.innerHeight - px;
stand.dispatchEvent(new Event("resize"));
}, covered);
}
async function openCaptureForm(page: Page) {
await page.goto("/");
await page.getByTestId("bottom-nav-capture").click();
const picker = page.locator('[data-slot="responsive-sheet-content"]');
await expect(picker).toBeVisible();
await picker
.locator('[data-slot="responsive-sheet-body"] button')
.first()
.click();
const form = page
.locator('[data-slot="responsive-sheet-content"]')
.filter({ has: page.locator('[data-slot="responsive-sheet-footer"]') });
await expect(form).toBeVisible();
return form;
}
/** Every visible field a phone can focus, with its computed font size. */
async function fieldFontSizes(page: Page) {
return page.evaluate(() =>
Array.from(
document.querySelectorAll<HTMLElement>(
"input:not([type=hidden]):not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file]),textarea,select",
),
)
.filter((el) => {
const r = el.getBoundingClientRect();
return r.width > 2 && r.height > 2;
})
.map((el) => ({
field:
el.getAttribute("name") ||
el.id ||
el.getAttribute("aria-label") ||
el.tagName,
px: parseFloat(getComputedStyle(el).fontSize),
})),
);
}
test.describe("installed app on a phone", () => {
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
test.beforeEach(({}, testInfo) => {
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
});
test("the new-version hint stands above the bottom bar and reloads", async ({
page,
}) => {
await page.route("**/api/version", (route) =>
route.fulfill({
json: {
data: { version: "999.0.0", buildSha: "e2e" },
error: null,
},
}),
);
await page.goto("/");
const toast = page.locator("[data-sonner-toast]").filter({
has: page.getByRole("button", { name: "Reload" }),
});
// The poller's first check runs five seconds after mount.
await expect(toast).toBeVisible({ timeout: 15_000 });
const nav = page.locator('[data-slot="bottom-nav"]');
const navBox = (await nav.boundingBox())!;
// Polled: the toast slides in from below, so an early sample is
// mid-animation.
await expect
.poll(async () => {
const b = (await toast.boundingBox())!;
return b.y + b.height;
})
.toBeLessThanOrEqual(navBox.y);
// The bottom bar's controls are still reachable with the hint up.
await expect(page.getByTestId("bottom-nav-more")).toBeVisible();
const reloaded = page.waitForEvent("load");
await toast.getByRole("button", { name: "Reload" }).click();
await reloaded;
});
test("a bottom sheet stands on the on-screen keyboard and the bottom bar steps aside", async ({
page,
}) => {
await page.addInitScript(VISUAL_VIEWPORT_STAND_IN);
const form = await openCaptureForm(page);
const save = form
.locator('[data-slot="responsive-sheet-footer"] button')
.last();
const viewportHeight = page.viewportSize()!.height;
const bottomEdge = async () => {
const b = (await form.boundingBox())!;
return Math.round(b.y + b.height);
};
// Closed keyboard: the sheet sits on the bottom edge (polled: it slides
// in from below).
await expect.poll(bottomEdge).toBe(viewportHeight);
const keyboard = 300;
await setKeyboard(page, keyboard);
await expect(page.locator("html")).toHaveAttribute("data-keyboard", "open");
await expect.poll(bottomEdge).toBe(viewportHeight - keyboard);
const box = (await form.boundingBox())!;
expect(box.y).toBeGreaterThanOrEqual(0);
const saveBox = (await save.boundingBox())!;
expect(saveBox.y + saveBox.height).toBeLessThanOrEqual(
viewportHeight - keyboard,
);
await expect(page.locator('[data-slot="bottom-nav"]')).toBeHidden();
// A toolbar-sized change is not a keyboard.
await setKeyboard(page, 60);
await expect(page.locator("html")).not.toHaveAttribute("data-keyboard");
await expect.poll(bottomEdge).toBe(viewportHeight);
});
for (const viewport of [
{ width: 390, height: 844 },
{ width: 844, height: 390 },
]) {
test(`no focusable field is below 16 px at ${viewport.width}x${viewport.height} (iOS focus zoom)`, async ({
page,
}) => {
await page.setViewportSize(viewport);
const offenders: string[] = [];
for (const path of [
"/settings",
"/documents",
"/settings/notifications",
]) {
await page.goto(path);
await page.waitForLoadState("networkidle");
const fields = await fieldFontSizes(page);
expect(fields.length, `${path} renders no field`).toBeGreaterThan(0);
for (const f of fields)
if (f.px < 16) offenders.push(`${path} ${f.field} ${f.px}px`);
}
if (viewport.width < 768) {
const form = await openCaptureForm(page);
await expect(form.locator("input").first()).toBeVisible();
for (const f of await fieldFontSizes(page))
if (f.px < 16) offenders.push(`capture ${f.field} ${f.px}px`);
}
expect(offenders).toEqual([]);
});
}
});
test.describe("signed out, held sideways", () => {
test.use({ viewport: { width: 844, height: 390 } });
test.beforeEach(({}, testInfo) => {
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
});
test("the sign-in card stays reachable from its top edge", async ({
page,
}) => {
await page.goto("/auth/login");
const card = page.locator("main#main-content > *").first();
await expect(card).toBeVisible();
// Open the password form, the tallest state of the card.
await page
.getByRole("button", { name: /password|passwort/i })
.first()
.click();
await expect(page.locator("input[type=password]").first()).toBeVisible();
const box = (await card.boundingBox())!;
expect(box.y).toBeGreaterThanOrEqual(0);
// And the document scrolls to reach whatever lies below the fold.
const scrollable = await page.evaluate(
() =>
document.documentElement.scrollHeight >= window.innerHeight &&
getComputedStyle(document.documentElement).overflowY !== "hidden",
);
expect(scrollable).toBe(true);
});
});
+64
View File
@@ -0,0 +1,64 @@
import { expect, test } from "./setup/test";
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* What the installed app shows without a connection, with the service worker
* actually running.
*
* Runs in the `chromium-service-worker` project, the one that lets the worker
* register (every other project blocks it; see `playwright.config.ts`), and
* like the other spec there it uses no route mocks: the claim is about what
* the worker itself serves.
*
* 1. A page that is open when the connection drops stays the app and says
* so (the offline strip).
* 2. Opening a page without a connection gets the worker's offline page:
* the word "Offline" and a working retry control, never the browser's
* error. (Signed-in pages are never cached, by design: the proxy pins
* `private, no-store` on them because some carry the record in the
* HTML. So this is what an offline launch of the installed app shows.)
* 3. When the connection returns, that page reloads into the app by
* itself.
*
* The account is the populated mobile-sweep account; nothing here writes.
*/
test.describe("offline, with the service worker", () => {
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
test("an open page says it is offline, a new one opens the offline page, and both recover", async ({
page,
context,
}) => {
await page.goto("/medications");
await page.waitForFunction(
() => navigator.serviceWorker?.controller !== null,
undefined,
{ timeout: 20_000 },
);
await expect(page.locator("#main-content")).toBeVisible();
await page.waitForLoadState("networkidle");
try {
await context.setOffline(true);
await expect(page.locator('[data-slot="offline-banner"]')).toBeVisible();
const response = await page.goto("/timeline");
expect(response?.status()).toBe(503);
await expect(page.getByText("Offline", { exact: true })).toBeVisible();
const retry = page.getByRole("link", { name: "Reload" });
await expect(retry).toBeVisible();
const box = (await retry.boundingBox())!;
expect(box.width).toBeGreaterThanOrEqual(44);
expect(box.height).toBeGreaterThanOrEqual(44);
// Back online: the offline page reloads itself into the app.
const reloaded = page.waitForEvent("load");
await context.setOffline(false);
await reloaded;
await expect(page.locator("#main-content")).toBeVisible();
expect(new URL(page.url()).pathname).toBe("/timeline");
} finally {
await context.setOffline(false);
}
});
});
+366
View File
@@ -0,0 +1,366 @@
import type { Page, Route } from "@playwright/test";
import { mockDay } from "./setup/day-mock";
import { mockPopulatedInsights } from "./utils/mock-populated-insights";
import { SCORE_HISTORY_STORAGE_STATE_PATH } from "./setup/global-setup";
import { expect, test } from "./setup/test";
import type { DailyDigest } from "@/lib/daily/digest";
import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures";
/**
* v1.42 — the score histories and the dashboard's top card switch.
*
* - The health score's page charts its daily course with the range tabs
* every chart offers; a tab asks the server for its window, the choice
* survives a reload, and a point opens its day.
* - Readiness, recovery, the sleep score and strain carry the same four
* tabs in the same component.
* - The top card goes away when the layout settings switch it off, and
* comes back when they switch it on.
*
* The score history and the derived scores are route mocks in the
* contract's shape (the server's answers are pinned by the integration
* suite); the range preference and the layout are this account's own, saved
* for real. Stable data attributes only.
*/
test.use({ storageState: SCORE_HISTORY_STORAGE_STATE_PATH });
// Every test writes this one account's layout blob, a Serializable
// read-modify-write; run in parallel they would abort each other's write.
test.describe.configure({ mode: "serial" });
const RANGES = ["7", "30", "90", "0"];
function dayKey(offset: number): string {
const now = new Date();
const local = new Date(
now.toLocaleString("en-US", { timeZone: "Europe/Berlin" }),
);
local.setDate(local.getDate() + offset);
const y = local.getFullYear();
const m = String(local.getMonth() + 1).padStart(2, "0");
const d = String(local.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
/** Answer the score history with one point per day of the window, max 40. */
async function mockScoreHistory(page: Page): Promise<number[]> {
const asked: number[] = [];
await page.route(/\/api\/insights\/score-history\?/, (route: Route) => {
const url = new URL(route.request().url());
const days = Number(url.searchParams.get("days"));
asked.push(days);
const count = Math.min(days, 40);
const points = Array.from({ length: count }, (_, i) => ({
day: dayKey(i - count + 1),
value: 60 + ((i * 7) % 25),
seamBreak: false,
}));
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
score: url.searchParams.get("score"),
days,
points,
band: { lo: 64, hi: 76, n: 30 },
},
error: null,
}),
});
});
return asked;
}
/** An `ok` derived score, so the score page draws its history under it. */
async function mockDerived(page: Page) {
await page.route(/\/api\/insights\/derived\?/, (route: Route) => {
const metric = new URL(route.request().url()).searchParams.get("metric");
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
metric,
status: "ok",
value: {
score: 72,
band: "green",
components: [],
subScores: [],
night: dayKey(0),
asleepMinutes: 420,
inBedMinutes: 450,
windowNights: 20,
},
coverage: {
requiredInputs: 1,
presentInputs: 1,
historyDays: 30,
missing: [],
},
confidence: "high",
provenance: {
inputs: [],
source: "rollup",
windowDays: 30,
computedAt: new Date().toISOString(),
},
reason: null,
assessment: null,
},
error: null,
}),
});
});
}
async function resetRange(page: Page, chartKey: string) {
const res = await page.request.put("/api/dashboard/chart-overlay-prefs", {
data: {
chartKey,
prefs: {
showTrendIndicator: false,
showTrendArrow: false,
showTargetRange: false,
comparisonBaseline: "none",
rangePoints: 30,
},
},
});
expect(res.status()).toBe(200);
}
async function expectSharedTabs(page: Page, chart: string, pressed: string) {
const tabs = page.locator(`${chart} [data-slot="chart-range-tab"]`);
await expect(tabs).toHaveCount(4);
expect(
await tabs.evaluateAll((nodes) =>
nodes.map((node) => node.getAttribute("data-range")),
),
).toEqual(RANGES);
await expect(
page.locator(`${chart} [data-slot="chart-range-tab"][aria-pressed="true"]`),
).toHaveAttribute("data-range", pressed);
}
test.describe("score histories", () => {
test.beforeEach(async ({ page, context }, testInfo) => {
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
await context.addCookies([
{ name: "healthlog-locale", value: "en", url: baseURL },
]);
await mockDay(page, { index: "full" });
});
test("the health score's range tabs switch the window and are remembered", async ({
page,
}) => {
await page.setViewportSize({ width: 1440, height: 900 });
await resetRange(page, "scoreHealth");
const asked = await mockScoreHistory(page);
await page.goto("/insights/health-score");
const chart = page.locator('[data-slot="score-trend-chart"]');
await expect(chart).toHaveAttribute("data-score", "HEALTH_SCORE");
await expectSharedTabs(page, '[data-slot="score-trend-chart"]', "30");
await expect(chart.locator('[data-slot="chart-plot"]')).toBeVisible();
await expect(chart.locator('[data-slot="score-trend-band"]')).toBeVisible();
expect(asked).toContain(30);
await chart
.locator('[data-slot="chart-range-tab"][data-range="7"]')
.click();
await expect(chart).toHaveAttribute("data-range", "7");
await expect.poll(() => asked.at(-1)).toBe(7);
// The table lists exactly the window's days.
await chart.locator('[data-slot="chart-data-table-toggle"]').click();
await expect(
chart.locator('[data-slot="chart-data-table-row"]'),
).toHaveCount(7);
// Remembered: a reload opens on seven days again.
await page.reload();
await expect(chart).toHaveAttribute("data-range", "7");
await chart
.locator('[data-slot="chart-range-tab"][data-range="0"]')
.click();
await expect(chart).toHaveAttribute("data-range", "0");
await expect.poll(() => asked.at(-1)).toBe(3650);
// A point opens its day beside the page.
const plot = chart.locator('[data-slot="chart-plot"]');
await expect(plot).toHaveAttribute("data-day-links", "true");
const box = (await plot.boundingBox())!;
await page.mouse.click(box.x + box.width * 0.8, box.y + box.height / 2);
await expect(page.locator('[data-slot="day-panel"]')).toHaveAttribute(
"data-shell",
"docked",
);
await expect(page).toHaveURL(/[?&]day=\d{4}-\d{2}-\d{2}/);
await resetRange(page, "scoreHealth");
});
test("the score panel leads to the health score page and back", async ({
page,
}) => {
await mockPopulatedInsights(page);
await mockScoreHistory(page);
await page.goto("/insights");
const link = page.locator('[data-slot="health-score-history-link"]');
await expect(link).toHaveAttribute("href", "/insights/health-score");
await link.click();
await expect(page).toHaveURL(/\/insights\/health-score$/);
await expect(
page.locator('[data-slot="health-score-back"]'),
).toHaveAttribute("href", "/insights");
});
test("readiness and the sleep score offer the same tabs, and switching asks for the window", async ({
page,
}) => {
await resetRange(page, "scoreReadiness");
await mockDerived(page);
const asked = await mockScoreHistory(page);
await page.goto("/insights/scores/readiness");
const chart = page.locator('[data-slot="score-trend-chart"]');
await expect(chart).toHaveAttribute("data-score", "READINESS");
await expectSharedTabs(page, '[data-slot="score-trend-chart"]', "30");
await chart
.locator('[data-slot="chart-range-tab"][data-range="90"]')
.click();
await expect(chart).toHaveAttribute("data-range", "90");
await expect.poll(() => asked.at(-1)).toBe(90);
await resetRange(page, "scoreReadiness");
await page.goto("/insights/scores/sleep");
await expect(
page.locator('[data-slot="score-trend-chart"]'),
).toHaveAttribute("data-score", "SLEEP_SCORE");
await expectSharedTabs(page, '[data-slot="score-trend-chart"]', "30");
});
test("recovery and strain carry the same tabs on their stored-score chart", async ({
page,
}) => {
await resetRange(page, "scoreRecovery");
await mockDerived(page);
await page.goto("/insights/scores/recovery");
const chart = '[data-slot="score-history-chart"]';
await expect(page.locator(chart)).toHaveAttribute(
"data-type",
"RECOVERY_SCORE",
);
await expectSharedTabs(page, chart, "30");
// A score history remembers its range, and mounts no overlay dropdown.
await expect(
page.locator(`${chart} [data-slot="chart-overlay-controls-trigger"]`),
).toHaveCount(0);
await page
.locator(`${chart} [data-slot="chart-range-tab"][data-range="7"]`)
.click();
await expectSharedTabs(page, chart, "7");
await page.reload();
await expectSharedTabs(page, chart, "7");
await resetRange(page, "scoreRecovery");
await page.goto("/insights/scores/strain");
await expectSharedTabs(page, chart, "30");
});
});
const DIGEST: DailyDigest = {
generatedAt: new Date().toISOString(),
ai: DIGEST_AI_AVAILABLE,
phase: "final",
sleepPending: false,
score: {
value: 82,
band: "green",
delta: null,
deltaReason: "first_eligibility_window",
steadyWeeks: 2,
},
topSignal: null,
signalLine: null,
briefingLead: null,
lead: null,
today: [],
restMode: null,
line: "Your health score today is 82.",
worthALook: [],
justIn: null,
reactionLine: null,
};
test.describe("the dashboard's top card", () => {
test.afterEach(async ({ page }) => {
// Leave the account's layout at its defaults for the next run.
await page.request.delete("/api/dashboard/widgets");
});
test("switches off and on from the layout settings", async ({ page }) => {
await page.request.delete("/api/dashboard/widgets");
await page.route(/\/api\/daily\/digest(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: DIGEST, error: null }),
}),
);
// The dashboard states the switch on its root. What the card shows
// depends on the day's digest, which the server renders into the page
// before any route mock can answer, so the switch is asserted there and
// not on the card's content.
const dashboard = page.locator("[data-today-card]");
const hero = page.locator('[data-slot="today-hero"]');
await page.goto("/");
await expect(dashboard).toHaveAttribute("data-today-card", "on");
async function flip(expected: "checked" | "unchecked") {
await page.goto("/settings/layout/dashboard");
const toggle = page.locator('[data-slot="today-card-switch"]');
await expect(toggle).toBeVisible();
await toggle.click();
await expect(toggle).toHaveAttribute("data-state", expected);
const saved = page.waitForResponse(
(res) =>
res.url().includes("/api/dashboard/widgets") &&
res.request().method() === "PUT",
);
await page
.locator('[data-slot="settings-dashboard-layout-save"]')
.click();
const response = await saved;
expect(response.status()).toBe(200);
const body = response.request().postDataJSON() as {
todayCardVisible?: boolean;
};
expect(body.todayCardVisible).toBe(expected === "checked");
}
await flip("unchecked");
await page.goto("/");
await expect(dashboard).toHaveAttribute("data-today-card", "off");
await expect(hero).toHaveCount(0);
await expect(page.locator('[data-slot="today-hero-skeleton"]')).toHaveCount(
0,
);
// A phone width shows no card either.
await page.setViewportSize({ width: 390, height: 844 });
await page.reload();
await expect(hero).toHaveCount(0);
await page.setViewportSize({ width: 1280, height: 720 });
await flip("checked");
await page.goto("/");
await expect(dashboard).toHaveAttribute("data-today-card", "on");
});
});
+220
View File
@@ -0,0 +1,220 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
INTEGRATION_CARD_STATES,
mockIntegrationCards,
} from "./setup/integration-cards-fixture";
/**
* Settings → Integrations: the shared integration card, one card per state.
*
* Every provider is mocked into a different `health` (see the fixture), and
* the page is checked at desktop width and at 390 px. Assertions read data
* attributes only: the card's `data-state`, the badge's `data-state`, the one
* primary button's `data-action`. The wording is pinned by the component
* tests against the bundle itself.
*
* Runs in the desktop project and sizes the page itself, so one run covers
* both widths without a second project driving the same account.
*/
const VIEWPORTS = [
{ name: "desktop", width: 1280, height: 900 },
{ name: "390 px", width: 390, height: 844 },
] as const;
test.describe("Settings → Integrations: one card per health state", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(({}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"sizes the viewport itself",
);
});
for (const viewport of VIEWPORTS) {
test(`renders each state from health alone at ${viewport.name}`, async ({
page,
}) => {
await page.setViewportSize(viewport);
await mockIntegrationCards(page);
await page.goto("/settings/integrations");
for (const [provider, expected] of Object.entries(
INTEGRATION_CARD_STATES,
)) {
const card = page.locator(
`[data-slot="integration-card"][data-integration="${provider}"]`,
);
await expect(card, provider).toHaveAttribute(
"data-state",
expected.state,
{ timeout: 15_000 },
);
const badge = card.locator('[data-slot="integration-health-badge"]');
if (expected.state === "not_connected") {
await expect(badge, provider).toHaveCount(0);
} else {
await expect(badge, provider).toHaveAttribute(
"data-state",
expected.state,
);
// The word is the accessible name, not decoration.
await expect(badge, provider).not.toHaveText("");
}
// At most one primary, and exactly the one the state names.
const primary = card.locator(
'[data-slot="integration-primary-action"]',
);
if (expected.primary === null) {
await expect(primary, provider).toHaveCount(0);
} else {
await expect(primary, provider).toHaveCount(1);
await expect(primary, provider).toHaveAttribute(
"data-action",
expected.primary,
);
if (viewport.width < 640) {
const box = await primary.boundingBox();
expect(
box?.height ?? 0,
`${provider} tap target`,
).toBeGreaterThanOrEqual(44);
}
}
}
// Nothing on the page states a status the old way.
await expect(
page.locator('[data-testid="integration-status-pill"]'),
).toHaveCount(0);
const dims = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
innerWidth: window.innerWidth,
}));
expect(
dims.scrollWidth,
`scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`,
).toBeLessThanOrEqual(dims.innerWidth + 1);
});
}
test("the menu works from the keyboard and keeps its confirmations", async ({
page,
}) => {
await mockIntegrationCards(page);
await page.goto("/settings/integrations");
const card = page.locator(
'[data-slot="integration-card"][data-integration="withings"]',
);
const trigger = card.locator('[data-slot="integration-menu-trigger"]');
await expect(trigger).toBeVisible({ timeout: 15_000 });
await trigger.focus();
await page.keyboard.press("Enter");
const menu = page.locator('[data-slot="integration-menu"]');
await expect(menu).toBeVisible();
await expect(
menu.locator('[data-slot="integration-menu-item"]'),
).toHaveCount(7);
// Disconnect asks first; dismissing the dialog does nothing.
await menu.locator('[data-action="disconnect"]').focus();
await page.keyboard.press("Enter");
const dialog = page.getByRole("alertdialog");
await expect(dialog).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
await expect(card).toHaveAttribute("data-state", "limited");
// Escape closes the menu and returns focus to its trigger.
await trigger.focus();
await page.keyboard.press("Enter");
await expect(menu).toBeVisible();
await page.keyboard.press("Escape");
await expect(menu).toHaveCount(0);
await expect(trigger).toBeFocused();
});
test("details open on request and Copy puts exactly the shown text on the clipboard", async ({
page,
context,
}) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await mockIntegrationCards(page);
await page.goto("/settings/integrations");
const card = page.locator(
'[data-slot="integration-card"][data-integration="polar"]',
);
const toggle = card.locator('[data-slot="integration-details-toggle"]');
const panel = card.locator('[data-slot="integration-details"]');
await expect(toggle).toHaveAttribute("aria-expanded", "false", {
timeout: 15_000,
});
await expect(panel).toBeHidden();
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(panel).toBeVisible();
const shown = await panel
.locator('[data-slot="integration-diagnostics"]')
.innerText();
expect(shown).toContain("degraded / provider_errors");
expect(shown).toContain("503");
await panel.locator('[data-slot="integration-diagnostics-copy"]').click();
const status = panel.locator(
'[data-slot="integration-diagnostics-copy-status"]',
);
await expect(status).toHaveAttribute("data-state", "copied");
await expect(status).toHaveAttribute("role", "status");
const copied = await page.evaluate(() => navigator.clipboard.readText());
expect(copied.trim()).toBe(shown.trim());
});
for (const theme of ["light", "dark"] as const) {
test(`every state reads accessibly in the ${theme} theme`, async ({
page,
}) => {
await page.emulateMedia({ colorScheme: theme, reducedMotion: "reduce" });
await page.addInitScript((explicit: string) => {
window.localStorage.setItem("healthlog-theme", explicit);
}, theme);
await mockIntegrationCards(page);
await page.goto("/settings/integrations");
const panel = page.locator('[data-slot="connections-panel"]');
await expect(
panel.locator(
'[data-slot="integration-card"][data-integration="whoop"]',
),
).toHaveAttribute("data-state", "action_required", { timeout: 15_000 });
// Scan with one details panel open, so the diagnostics block and its
// copy button are part of what is checked.
await panel
.locator(
'[data-integration="polar"] [data-slot="integration-details-toggle"]',
)
.click();
const results = await new AxeBuilder({ page })
.include('[data-slot="connections-panel"]')
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
const serious = results.violations
.filter((v) => v.impact === "serious" || v.impact === "critical")
.map(
(v) =>
`${v.id}: ${v.nodes.map((n) => n.target.join(" ")).join(", ")}`,
);
expect(serious).toEqual([]);
});
}
});
+96 -79
View File
@@ -3,22 +3,16 @@ import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.19 phase A5 — `/settings/integrations` on a Pixel-5 viewport
* (393 CSS px) must:
* `/settings/integrations` on a Pixel-5 viewport (393 CSS px) must:
*
* 1. Not produce horizontal page overflow. The new
* IntegrationStatusPill is `whitespace-nowrap` so the chip
* itself can grow as wide as its longest possible label
* ("Verbunden · vor 12 min" / "Error — reconnect"); the parent
* header is a `flex-wrap` so the pill drops to its own line if
* the title and pill together exceed the card width — this spec
* verifies neither path leaks into a horizontal scrollbar on the
* <html> element.
* 1. Not produce horizontal page overflow. The badge in each card header is
* `whitespace-nowrap`; the header row wraps so the badge drops under the
* title when both do not fit. Neither path may leak into a horizontal
* scrollbar on the <html> element.
*
* 2. Render exactly one pill per integration card. Belt-and-braces
* guard for the card consolidation — the Vitest spec covers this
* on the SSR markup level, but the Pixel-5 layout is what matters
* in practice.
* 2. Render exactly one status badge per connected card, and none on a card
* that is not connected. The Vitest spec covers this on the SSR markup;
* the Pixel-5 layout is what matters in practice.
*
* Mobile-only spec — desktop project skips it.
*/
@@ -29,7 +23,7 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
});
test("Pixel-5 viewport: no horizontal page scroll, one pill per card", async ({
test("Pixel-5 viewport: no horizontal page scroll, one badge per connected card", async ({
page,
}) => {
// Stub the three endpoints the Integrations section reads — both
@@ -66,42 +60,56 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
integrations: [
{
integration: "withings",
state: "connected",
lastSuccessAt: recent,
lastAttemptAt: recent,
lastError: null,
consecutiveFailures: 0,
// 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`; a card-less pill keys on `configured`.
configured: true,
connected: true,
connectedAt: "2026-04-01T12:00:00Z",
legacyLastSyncedAt: recent,
tokenExpiresAt: "2026-12-01T12:00:00Z",
tokenExpired: false,
scope: "user.info,user.metrics,user.activity",
hasActivityScope: true,
syncHealth: { verdict: "fresh", since: null },
health: {
state: "ok",
reason: "data_arriving",
since: null,
action: null,
unavailable: [],
lastSuccessAt: recent,
lastDataAt: recent,
diagnostics: {
ledgerState: "connected",
failingLegs: [],
failingSince: null,
lastAttemptAt: recent,
verdict: "fresh",
recentCalls: [],
},
},
metricFreshness: [
{ type: "WEIGHT", lastSeenAt: recent, stale: false },
],
},
{
// 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.
// Configured, card-less, and no longer syncing: 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,
legacyLastSyncedAt: monthAgo,
syncHealth: { verdict: "stalled", since: monthAgo },
connected: true,
health: {
state: "degraded",
reason: "sync_stalled",
since: monthAgo,
action: null,
unavailable: [],
lastSuccessAt: monthAgo,
lastDataAt: monthAgo,
diagnostics: {
ledgerState: "connected",
failingLegs: [],
failingSince: null,
lastAttemptAt: monthAgo,
verdict: "stalled",
recentCalls: [],
},
},
metricFreshness: [],
},
// v1.28.x — Strava OAuth card reads off this consolidated
@@ -109,30 +117,23 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
// never resolves and the integrations page hangs.
{
integration: "strava",
state: "disconnected",
lastSuccessAt: null,
lastAttemptAt: null,
lastError: null,
connected: false,
configured: false,
available: false,
hasOwnCredentials: false,
syncHealth: { verdict: "disconnected", since: null },
health: null,
metricFreshness: [],
},
// Nightscout reads the envelope too since the card dropped its
// own status round-trip; without an entry its pill never resolves.
// own status round-trip; without an entry its card never settles.
{
integration: "nightscout",
state: "disconnected",
lastSuccessAt: null,
lastAttemptAt: null,
lastError: null,
connected: false,
configured: false,
hasToken: false,
allowPrivateHost: false,
syncHealth: { verdict: "disconnected", since: null },
health: null,
metricFreshness: [],
},
],
@@ -142,6 +143,19 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
}),
);
// Apple Health has delivered nothing on this account: not connected, so
// no badge. Mocked so a seeded HealthKit row cannot add one.
await page.route("**/api/integrations/healthkit", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { entries: [], lastSyncedAt: null, health: null },
error: null,
}),
}),
);
await page.route("**/api/withings/status", (route) =>
route.fulfill({
status: 200,
@@ -164,42 +178,45 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
});
await page.waitForLoadState("networkidle");
// Wait for the pills to mount (queries resolve after first paint).
// One pill per card: Withings, WHOOP, Fitbit, Google Health, Polar,
// 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(9, { timeout: 10_000 });
// Wait for the cards to settle (queries resolve after first paint).
// Withings and the card-less fallback are connected: one badge each.
// Every provider without an envelope entry stays in its loading state,
// and the not-connected ones carry no badge at all.
const badges = page.locator('[data-slot="integration-health-badge"]');
await expect(badges).toHaveCount(2, { 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.
// which has stopped syncing, is visible on the page, read-only.
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");
fallback.locator('[data-slot="integration-health-badge"]'),
).toHaveAttribute("data-state", "degraded");
await expect(
fallback.locator('[data-slot="integration-menu-trigger"]'),
).toHaveCount(0);
// Card anatomy is uniform because no integration card paints a rule under
// its header any more. The `<hr>` existed only in this folder — nine cards
// carrying a separator no other Settings card has — and the card's own gap
// does the separating now.
// its header any more.
await expect(
page.locator('[data-testid="integration-card-divider"]'),
).toHaveCount(0);
// Withings is connected in this fixture; WHOOP is a BYO-keys card that
// stays dormant until an operator adds credentials, so assert the one
// connected pill.
await expect(
page.locator(
'[data-testid="integration-status-pill"][data-state="connected"]',
'[data-slot="integration-card"][data-integration="withings"]',
),
).toHaveCount(1);
).toHaveAttribute("data-state", "ok");
await expect(
page.locator('[data-slot="integration-card"][data-integration="strava"]'),
).toHaveAttribute("data-state", "not_connected");
// The redundant v1.4.18 banner must be gone.
// The retired status surfaces stay gone.
await expect(
page.locator('[data-testid="integration-status-pill"]'),
).toHaveCount(0);
await expect(
page.locator('[data-testid="integration-status-banner"]'),
).toHaveCount(0);
@@ -215,12 +232,11 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
`scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`,
).toBeLessThanOrEqual(dims.innerWidth + 1);
// Pill text remains legible at this viewport — assert non-empty
// bounding rect so we know it's painted, not display:none-d by
// some surprising container query.
// Badge text remains legible at this viewport: painted, not hidden by a
// surprising container query.
for (let i = 0; i < 2; i++) {
const box = await pills.nth(i).boundingBox();
expect(box, `pill ${i} has no bounding box`).not.toBeNull();
const box = await badges.nth(i).boundingBox();
expect(box, `badge ${i} has no bounding box`).not.toBeNull();
expect(box!.width).toBeGreaterThan(0);
expect(box!.height).toBeGreaterThan(0);
}
@@ -241,7 +257,8 @@ test.describe("Apple Health guidance", () => {
data: {
entries: [],
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
// Nothing delivered yet: no health, so the card is not connected.
health: null,
metricFreshness: [],
// #778 — nothing accepted yet, so the card must show no
// progress section for this pending-first-sync account.
@@ -268,10 +285,10 @@ test.describe("Apple Health guidance", () => {
// A raw key means the bundle lookup failed, which is the failure this
// assertion can actually see from a browser.
await expect(description).not.toContainText("settings.appleHealth");
await expect(page.getByTestId("apple-health-status")).toHaveAttribute(
"data-state",
"pending-setup",
);
await expect(card).toHaveAttribute("data-state", "not_connected");
await expect(
card.locator('[data-slot="integration-health-badge"]'),
).toHaveCount(0);
const importLink = page.getByTestId("apple-health-import-link");
await expect(importLink).toHaveAttribute(
+24 -20
View File
@@ -149,38 +149,42 @@ test.describe("Settings mobile consistency (Pixel 5)", () => {
}
});
test("/settings/account: Sprache select shares one grid row with date-of-birth", async ({
test("/settings/account: the language select sits in the display card, outside the profile form", async ({
page,
}) => {
await page.goto("/settings/account", { waitUntil: "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.
// Gate on the elements this test is about, not on the network: the
// single `evaluate` below does not retry.
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
// a uniform two-column rhythm and the language field no longer
// sits alone at the bottom with a `sm:max-w-xs` clamp.
const sharedGrid = await page.evaluate(() => {
// The language, unit and format selects save the moment they change, so
// they live in their own card with no save button. Inside the profile
// form they read as covered by its Save. Each card keeps a two-column
// grid: the language shares one with the units, the date of birth one
// with the timezone.
const layout = await page.evaluate(() => {
const lang = document.getElementById("language-select");
const dob = document.getElementById("dob");
if (!lang || !dob) return { found: false, sharedGrid: false };
const langGrid = lang.closest('[class*="grid"]');
const dobGrid = dob.closest('[class*="grid"]');
if (!lang || !dob) return null;
const displayCard = document.querySelector(
'[data-testid="settings-display-card"]',
);
return {
found: true,
sharedGrid: langGrid !== null && langGrid === dobGrid,
langInDisplayCard: !!displayCard && displayCard.contains(lang),
langInForm: lang.closest("form") !== null,
dobInForm: dob.closest("form") !== null,
langHasGrid: lang.closest('[class*="grid"]') !== null,
dobHasGrid: dob.closest('[class*="grid"]') !== null,
};
});
expect(sharedGrid.found, "language + dob fields must exist").toBe(true);
expect(sharedGrid.sharedGrid, "language + dob must share a grid").toBe(
true,
);
expect(layout, "language + dob fields must exist").not.toBeNull();
expect(layout!.langInDisplayCard).toBe(true);
expect(layout!.langInForm).toBe(false);
expect(layout!.dobInForm).toBe(true);
expect(layout!.langHasGrid).toBe(true);
expect(layout!.dobHasGrid).toBe(true);
});
test("/: Compare-to trigger renders at 44 px", async ({ page }) => {
+90
View File
@@ -0,0 +1,90 @@
/**
* The record `day-doors.spec.ts` opens days from.
*
* Every chart the journey clicks needs points to click: ten days of the
* autonomic charge (the recovery page), of sleep and of mood (the sleep
* chart, the mood line and the mood calendar), weight over the last ten days
* and the same ten days a month earlier (the comparison chart draws a prior
* period only where one exists), and two readings of one lab marker.
*
* Written through the app's own routes, once, from `globalSetup`. An account
* that already holds the lab marker is taken as seeded, so a local re-run
* against a reused database does not double every row.
*/
import { request as playwrightRequest } from "@playwright/test";
export const DAY_DOORS_ANALYTE = "Ferritin";
export async function seedDayDoorsRecord(
baseURL: string,
storageStatePath: string,
): Promise<void> {
const ctx = await playwrightRequest.newContext({
baseURL,
storageState: storageStatePath,
});
try {
const labs = await ctx.get("/api/labs");
const body = (await labs.json()) as { data: unknown };
if (JSON.stringify(body.data ?? null).includes(DAY_DOORS_ANALYTE)) return;
const post = async (path: string, data: unknown): Promise<void> => {
const res = await ctx.post(path, { data });
if (res.status() === 409 || res.ok()) return;
throw new Error(
`[day-doors-fixture] ${path} answered ${res.status()}: ${(await res.text()).slice(0, 200)}`,
);
};
const daysAgo = (days: number, hour = 8) => {
const at = new Date(Date.now() - days * 86_400_000);
at.setUTCHours(hour, 0, 0, 0);
return at.toISOString();
};
for (let day = 1; day <= 10; day += 1) {
await post("/api/measurements", {
type: "ANS_CHARGE",
value: 5 + day,
measuredAt: daysAgo(day, 5),
});
await post("/api/measurements", {
type: "SLEEP_DURATION",
value: 400 + day * 4,
measuredAt: daysAgo(day, 6),
});
await post("/api/measurements", {
type: "WEIGHT",
value: 72 + day / 10,
measuredAt: daysAgo(day, 7),
});
// The same ten days, a month before: the prior period the comparison
// draws beside them.
await post("/api/measurements", {
type: "WEIGHT",
value: 73 + day / 10,
measuredAt: daysAgo(day + 30, 7),
});
await post("/api/mood-entries", {
mood: day % 2 ? "GUT" : "OKAY",
moodLoggedAt: daysAgo(day, 19),
});
}
for (const [days, value] of [
[60, 41],
[4, 58],
] as const) {
await post("/api/labs", {
analyte: DAY_DOORS_ANALYTE,
panel: "Eisenstoffwechsel",
value,
unit: "ng/mL",
referenceLow: 30,
referenceHigh: 300,
takenAt: daysAgo(days),
});
}
} finally {
await ctx.dispose();
}
}
+125
View File
@@ -0,0 +1,125 @@
/**
* The day routes, answered in the contract's shape.
*
* The day itself (`GET /api/day/{date}`) and the index (`GET /api/day/index`)
* are route mocks, so a journey pins the client: which door opens which day,
* what the history does, where no door is. The server's own answers are
* pinned by the integration suite.
*/
import type { Page, Route } from "@playwright/test";
function dayFixture(date: string) {
return {
date,
tz: "Europe/Berlin",
counts: { values: 2, entries: 1 },
running: [
{
kind: "illness",
section: "illness",
id: "ep1",
title: "Common cold",
sub: null,
since: date,
until: null,
dayIndex: 1,
dayCount: null,
href: "/illness",
},
],
values: [
{
type: "BLOOD_PRESSURE_SYS",
value: 124,
unit: "mmHg",
at: `${date}T07:00:00.000Z`,
source: "MANUAL",
band: { lo: 118, hi: 128, n: 20 },
},
{
type: "BLOOD_PRESSURE_DIA",
value: 79,
unit: "mmHg",
at: `${date}T07:00:00.000Z`,
source: "MANUAL",
band: { lo: 76, hi: 82, n: 20 },
},
],
events: [
{
at: `${date}T07:00:00.000Z`,
kind: "mood",
section: "mood",
id: "m1",
title: "Mood: good",
meta: null,
note: null,
docs: [],
href: "/mood",
},
],
notable: [],
scores: [
{
key: "healthScore",
value: 74,
max: 100,
source: "COMPUTED",
band: { lo: 66, hi: 78, n: 30 },
},
{ key: "strain", value: 12.4, max: 21, source: "WHOOP", band: null },
],
sections: {},
};
}
/**
* Every day from `from` to `to`, each holding values: what the row of day
* dots under a chart reads when the whole window has entries.
*/
function everyDay(from: string, to: string): Record<string, string[]> {
const days: Record<string, string[]> = {};
if (!from || !to) return days;
const at = new Date(`${from}T12:00:00.000Z`);
const end = new Date(`${to}T12:00:00.000Z`);
while (at <= end) {
days[at.toISOString().slice(0, 10)] = ["values"];
at.setUTCDate(at.getUTCDate() + 1);
}
return days;
}
export async function mockDay(
page: Page,
options: { index?: "empty" | "full" } = {},
) {
await page.route("**/api/day/**", async (route: Route) => {
const url = new URL(route.request().url());
if (url.pathname === "/api/day/index") {
const from = url.searchParams.get("from") ?? "";
const to = url.searchParams.get("to") ?? "";
const days = options.index === "full" ? everyDay(from, to) : {};
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
from,
to,
days,
// The first day of the window stands out: the rug draws a ring.
notable: Object.keys(days).slice(0, 1),
},
error: null,
}),
});
return;
}
const date = url.pathname.split("/").pop() ?? "";
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: dayFixture(date), error: null }),
});
});
}
+144
View File
@@ -33,6 +33,7 @@ import {
} from "@playwright/test";
import pg from "pg";
import { seedDayDoorsRecord } from "./day-doors-fixture";
import { seedMobileRoutesRecord } from "./mobile-routes-fixture";
// Resolve relative to the project root rather than __dirname/import.meta.url —
@@ -229,6 +230,65 @@ export const E2E_AI_OPTIONAL = {
role: "USER",
} as const;
/**
* The timeline journey's own account (`timeline.spec.ts`).
*
* It switches the opt-in timeline module on and off, which changes the
* navigation, the add menus and Settings for the whole account; on the shared
* account a parallel spec would see them move underneath it. The module
* starts off here, the way every account starts.
*/
export const E2E_TIMELINE = {
email: "e2e-timeline@healthlog.test",
username: "e2e-timeline",
password: "Tl6!Wq3zRm8vXn2K",
role: "USER",
} as const;
export const TIMELINE_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateTimeline.json",
);
/**
* The day-doors journey's own account (`day-doors.spec.ts`).
*
* It turns a chart's comparison overlay on, which is a per-account
* preference; on a shared account a parallel spec would find its chart
* drawing a prior period it never asked for. Its record carries the series
* the journey clicks (recovery, sleep, mood, weight, a lab marker).
*/
export const E2E_DAY_DOORS = {
email: "e2e-day-doors@healthlog.test",
username: "e2e-day-doors",
password: "Dy4!Rk8wQm2vTz6P",
role: "USER",
} as const;
export const DAY_DOORS_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateDayDoors.json",
);
/**
* The score-history journey's own account (`score-history.spec.ts`).
*
* It switches the dashboard's top card off and back on and remembers a range
* tab per score page, both per-account preferences; on a shared account a
* parallel spec would find its dashboard without the card it asserts.
*/
export const E2E_SCORE_HISTORY = {
email: "e2e-score-history@healthlog.test",
username: "e2e-score-history",
password: "Sh7!Vn2qLp9xWc4R",
role: "USER",
} as const;
export const SCORE_HISTORY_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateScoreHistory.json",
);
export const AI_OPTIONAL_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateAiOptional.json",
@@ -906,6 +966,80 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
],
);
// The timeline journey's account. The module starts off; the journey
// switches it through the real Settings switch.
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role, created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed,
module_preferences_json)
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, NULL)
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash,
updated_at = EXCLUDED.updated_at,
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
module_preferences_json = NULL`,
[
cuid(),
E2E_TIMELINE.username,
E2E_TIMELINE.email,
await hashPassword(E2E_TIMELINE.password),
now,
],
);
// The day-doors journey's account. Every module the journey reaches is
// on; its record is written after the login capture.
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role, created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed,
module_preferences_json)
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, NULL)
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash,
updated_at = EXCLUDED.updated_at,
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
module_preferences_json = NULL`,
[
cuid(),
E2E_DAY_DOORS.username,
E2E_DAY_DOORS.email,
await hashPassword(E2E_DAY_DOORS.password),
now,
],
);
// The score-history journey's account. Every module on, a fresh layout:
// the journey moves its top card and range tabs, so a re-run starts from
// the defaults.
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role, created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed,
module_preferences_json)
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, NULL)
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash,
updated_at = EXCLUDED.updated_at,
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
module_preferences_json = NULL,
dashboard_widgets_json = NULL`,
[
cuid(),
E2E_SCORE_HISTORY.username,
E2E_SCORE_HISTORY.email,
await hashPassword(E2E_SCORE_HISTORY.password),
now,
],
);
// The notification-dispatch journey's account. Seeded like the others;
// its channels, devices, ledger rows and preferences are reset by
// `e2e/setup/notification-fixture.ts` before every test, because the
@@ -1509,6 +1643,16 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
// The AI-optional journeys' jar — see `E2E_AI_OPTIONAL`.
await capture(E2E_AI_OPTIONAL, AI_OPTIONAL_STORAGE_STATE_PATH);
// The timeline journey's jar — see `E2E_TIMELINE`.
await capture(E2E_TIMELINE, TIMELINE_STORAGE_STATE_PATH);
// The day-doors journey's jar, then its record — see `E2E_DAY_DOORS`.
await capture(E2E_DAY_DOORS, DAY_DOORS_STORAGE_STATE_PATH);
await seedDayDoorsRecord(baseURL, DAY_DOORS_STORAGE_STATE_PATH);
// The score-history journey's jar — see `E2E_SCORE_HISTORY`.
await capture(E2E_SCORE_HISTORY, SCORE_HISTORY_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, SCOPE_CAPABILITIES_STORAGE_STATE_PATH);
+248
View File
@@ -0,0 +1,248 @@
import type { Page } from "@playwright/test";
/**
* Settings → Integrations with one card per health state, all mocked.
*
* Each provider carries the `health` the server would publish for one
* situation, so a single page shows every state the shared card can be in:
*
* withings limited / plan_limited (no button)
* oura limited / permission_limited (one Reconnect)
* fitbit ok / data_arriving (older history loading)
* polar degraded / provider_errors (no button)
* whoop action_required / paused (one Resume)
* google-health action_required / reauth (one Reconnect)
* strava not connected (Connect)
* nightscout not connected (address form)
* apple-health degraded / no_new_data
*
* Timestamps are relative to now, so the sentences read the same on any day.
*/
const HOUR = 60 * 60 * 1000;
function ago(hours: number): string {
return new Date(Date.now() - hours * HOUR).toISOString();
}
function health(overrides: Record<string, unknown> = {}) {
return {
state: "ok",
reason: "data_arriving",
since: null,
action: null,
unavailable: [],
lastSuccessAt: ago(1),
lastDataAt: ago(2),
diagnostics: {
ledgerState: "connected",
failingLegs: [],
failingSince: null,
lastAttemptAt: ago(1),
verdict: "fresh",
recentCalls: [
{ endpoint: "measures", status: "ok", at: ago(1), errorCode: null },
],
},
...overrides,
};
}
function connected(integration: string, h: Record<string, unknown>) {
return {
integration,
lastError: null,
connected: true,
configured: true,
available: true,
hasOwnCredentials: true,
health: h,
metricFreshness: [
{ type: "WEIGHT", lastSeenAt: ago(2), stale: false },
{ type: "PULSE", lastSeenAt: ago(80), stale: true },
],
};
}
function disconnected(integration: string) {
return {
integration,
lastError: null,
connected: false,
configured: false,
available: true,
hasOwnCredentials: false,
health: null,
metricFreshness: [],
};
}
export const INTEGRATION_CARD_STATES = {
withings: { state: "limited", primary: null },
oura: { state: "limited", primary: "reconnect" },
fitbit: { state: "ok", primary: null },
polar: { state: "degraded", primary: null },
whoop: { state: "action_required", primary: "resume" },
"google-health": { state: "action_required", primary: "reconnect" },
strava: { state: "not_connected", primary: "connect" },
nightscout: { state: "not_connected", primary: "connect" },
"apple-health": { state: "degraded", primary: null },
} as const;
export function integrationCardsEnvelope() {
return {
threshold: 3,
metricFreshnessDegraded: false,
integrations: [
connected(
"withings",
health({
state: "limited",
reason: "plan_limited",
unavailable: [
{ item: "spo2", cause: "plan" },
{ item: "vascularAge", cause: "plan" },
{ item: "pulseWaveVelocity", cause: "plan" },
{ item: "vo2Max", cause: "plan" },
{ item: "ecg", cause: "plan" },
],
}),
),
{
...connected("whoop", {
...health({
state: "action_required",
reason: "paused_after_errors",
since: ago(50),
action: "resume",
lastDataAt: ago(52),
}),
diagnostics: {
ledgerState: "parked",
failingLegs: ["recovery", "sleep"],
failingSince: ago(50),
lastAttemptAt: ago(3),
verdict: "parked",
recentCalls: [
{
endpoint: "recovery",
status: "persistent",
at: ago(3),
errorCode: "500",
},
{
endpoint: "sleep",
status: "persistent",
at: ago(3),
errorCode: "500",
},
],
},
}),
consecutiveFailuresByKind: {
transient: 0,
reauth_required: 0,
persistent: 4,
},
},
{
...connected("fitbit", health()),
backfillCompleted: false,
},
connected(
"google-health",
health({
state: "action_required",
reason: "reauth_required",
action: "reconnect",
since: ago(30),
lastDataAt: ago(31),
}),
),
connected(
"polar",
health({
state: "degraded",
reason: "provider_errors",
since: ago(9),
lastDataAt: ago(20),
diagnostics: {
ledgerState: "error_transient",
failingLegs: ["sleep"],
failingSince: ago(9),
lastAttemptAt: ago(1),
verdict: "failing",
recentCalls: [
{
endpoint: "sleep",
status: "transient",
at: ago(1),
errorCode: "503",
},
{
endpoint: "sleep",
status: "transient",
at: ago(2),
errorCode: "503",
},
{
endpoint: "activity",
status: "ok",
at: ago(2),
errorCode: null,
},
],
},
}),
),
connected(
"oura",
health({
state: "limited",
reason: "permission_limited",
action: "reconnect",
unavailable: [
{ item: "spo2", cause: "permission" },
{ item: "vo2max", cause: "permission" },
],
}),
),
disconnected("strava"),
{ ...disconnected("nightscout"), hasToken: false },
],
};
}
export function appleHealthStatus() {
return {
entries: [],
lastSyncedAt: ago(60),
lastSyncTrigger: "foreground",
lastBackgroundSyncAt: null,
metricFreshness: [],
syncProgress: { recordsAccepted: 48211, oldestMeasuredAt: ago(24 * 900) },
health: health({
state: "degraded",
reason: "no_new_data",
since: ago(60),
lastDataAt: ago(60),
}),
};
}
/** Route every read the Integrations page makes onto the fixture. */
export async function mockIntegrationCards(page: Page): Promise<void> {
await page.route("**/api/integrations/status", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: integrationCardsEnvelope(), error: null }),
}),
);
await page.route("**/api/integrations/healthkit", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: appleHealthStatus(), error: null }),
}),
);
}
+36
View File
@@ -133,7 +133,43 @@ test.describe("sidebar width transition", () => {
});
// With room to spare there is no edge: the default rail is unchanged.
// No native scrollbar in the rail (a classic one with arrow buttons
// read as a foreign body); a fade marks the edge that has more.
expect(
await nav.evaluate((el) => getComputedStyle(el).scrollbarWidth),
).toBe("none");
expect(
await nav.evaluate(
(el) => (el as HTMLElement).offsetWidth - el.clientWidth,
),
).toBe(0);
await expect(nav).toHaveAttribute("data-fade-top", "true");
await expect(nav).not.toHaveAttribute("data-fade-bottom", "true");
await nav.evaluate((el) => {
el.scrollTop = 0;
});
await expect(nav).toHaveAttribute("data-fade-bottom", "true");
await expect(nav).not.toHaveAttribute("data-fade-top", "true");
await page.setViewportSize({ width: 1280, height: 1400 });
await expect(footer).not.toHaveAttribute("data-nav-overflows", "true");
await expect(nav).not.toHaveAttribute("data-fade-bottom", "true");
});
test("a 1366 × 768 window fits the rail without scrolling, collapsed and expanded", async ({
page,
}) => {
await page.setViewportSize({ width: 1366, height: 768 });
await page.goto("/", { waitUntil: "domcontentloaded" });
const nav = page.locator("aside nav").first();
const toggle = page.locator('[data-slot="sidebar-collapse-toggle"]');
await expect(nav).toBeVisible();
for (let i = 0; i < 2; i++) {
await expect
.poll(() => nav.evaluate((el) => el.scrollHeight - el.clientHeight))
.toBeLessThanOrEqual(0);
await toggle.click();
await page.waitForTimeout(300);
}
});
});
+1007
View File
File diff suppressed because it is too large. Load diff
+12
View File
@@ -162,8 +162,17 @@ test("a fresh arrival flips the day in place", async ({ page }) => {
// swept by the same `e2e-just-in-` purge on the next run.
const priorNight = new Date(Date.now() - 3 * 24 * 60 * 60_000);
const priorWoke = new Date(priorNight.getTime() + 7 * 60 * 60_000);
//
// Tagged `foreground`, the trigger an open app sends. Since v1.42 a batch
// without it only marks the dashboard snapshot stale, and `cachedSwr`
// serves the stale cell once while it rebuilds: a snapshot an earlier
// spec left for this account (no sleep at all, so no sleep expected)
// would then paint the BEFORE state as `final`, depending on which spec
// happened to run first in the shard. `foreground` evicts, so the first
// read below is built from the rows this spec just wrote.
const priorBatch = await page.request.post("/api/measurements/batch", {
data: {
syncTrigger: "foreground",
entries: [
{
hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis",
@@ -229,8 +238,11 @@ test("a fresh arrival flips the day in place", async ({ page }) => {
const wokeAt = new Date(now.getTime() - 60 * 60_000);
const fellAsleepAt = new Date(wokeAt.getTime() - 7 * 60 * 60_000);
// `foreground` for the same reason as the prior night above: the page
// is open while it lands.
const batch = await page.request.post("/api/measurements/batch", {
data: {
syncTrigger: "foreground",
entries: [
{
hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis",
+10 -2
View File
@@ -23,13 +23,21 @@ function correlation(r: number, strength: string, n: number) {
};
}
/**
* A calendar day this many days back, so the heatmap cells stay inside its
* 90-day window whenever the suite runs.
*/
function dayKeyDaysAgo(days: number): string {
return new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10);
}
const MOOD_INSIGHTS_PAYLOAD = {
summary: { totalEntries: 269, inTargetPct: 62 },
heatmap: {
windowDays: 90,
cells: [
{ date: "2026-07-01", score: 4, samples: 2 },
{ date: "2026-07-02", score: 3, samples: 1 },
{ date: dayKeyDaysAgo(14), score: 4, samples: 2 },
{ date: dayKeyDaysAgo(13), score: 3, samples: 1 },
],
},
distribution: [],
+913
View File
@@ -0,0 +1,913 @@
import type { Page, Route } from "@playwright/test";
/**
* Route mocks for the timeline (v1.42, #613): `GET /api/timeline`,
* `GET /api/timeline/readiness` and `/api/life-events`, answered in the
* shapes of `src/lib/day/contract.ts` from an invented record. Two records:
* `full` (years of history in every lane) and `ready` (a thin record that
* starts in May 2024, a medication without a start date, no life events, no
* vaccinations), matching the two states the design was approved in.
*
* Every value here is made up.
*/
type Item = {
id: string;
kind: string;
start: string;
end: string | null;
open: boolean;
precision: "DAY" | "MONTH" | "YEAR";
startKnown: boolean;
label: string;
sub: string | null;
href: string | null;
/** The medication an item belongs to; one row per group. */
group: string | null;
};
function item(over: Partial<Item> & Pick<Item, "id" | "kind" | "start">): Item {
return {
end: null,
open: false,
precision: "DAY",
startKnown: true,
label: over.id,
sub: null,
href: null,
group: null,
...over,
};
}
/** A small deterministic generator, so every run draws the same lines. */
function rng(seed: number) {
let s = seed;
return () => {
s = (s * 16807) % 2147483647;
return (s - 1) / 2147483646;
};
}
type Bucket = "quarter" | "month" | "week" | "day";
/** The bucket the server picks for a zoom (`timelineBucket`). */
export function bucketFor(
zoom: string,
from?: string | null,
to?: string | null,
): Bucket {
if (zoom === "range" && from && to) {
// `rangeBucket`: by the range's length in days.
const days = dayNum(to) - dayNum(from) + 1;
return days >= 730
? "quarter"
: days >= 120
? "month"
: days >= 42
? "week"
: "day";
}
return zoom === "quarter" ? "week" : zoom === "year" ? "month" : "quarter";
}
function dayNum(key: string): number {
const [y, m, d] = key.split("-").map(Number);
return Math.round(Date.UTC(y, m - 1, d) / 86_400_000);
}
function dayStr(n: number): string {
return new Date(n * 86_400_000).toISOString().slice(0, 10);
}
/** Bucket starts from the one holding `from` to the one holding `to`. */
function bucketStarts(from: string, to: string, bucket: Bucket): string[] {
const out: string[] = [];
if (bucket === "day") {
for (let d = dayNum(from); d <= dayNum(to); d++) out.push(dayStr(d));
return out;
}
if (bucket === "week") {
const n = dayNum(from);
// 1970-01-01 was a Thursday: day 4 is the first Monday.
for (let d = n - ((((n - 4) % 7) + 7) % 7); d <= dayNum(to); d += 7) {
out.push(dayStr(d));
}
return out;
}
const step = bucket === "quarter" ? 3 : 1;
let [y, m] = from.split("-").map(Number);
m -= (m - 1) % step;
const [ty, tm] = to.split("-").map(Number);
while (y < ty || (y === ty && m <= tm)) {
out.push(`${y}-${String(m).padStart(2, "0")}-01`);
m += step;
if (m > 12) {
m -= 12;
y++;
}
}
return out;
}
/**
* Systolic pressure leaves gaps the chart has to show honestly: in the
* quarterly view one missing quarter (Q2 2023, bridged dashed) and three
* (2021, the line breaks). Q1 2022 rests on two readings in every series.
*/
export const SYS_MISSING = new Set([
"2021-01-01",
"2021-04-01",
"2021-07-01",
"2023-04-01",
]);
export const SYS_THIN = "2022-01-01";
function points(
from: string,
to: string,
bucket: Bucket,
value: (i: number, key: string) => number,
skip: (key: string) => boolean = () => false,
) {
const starts = bucketStarts(from, to, bucket);
return starts
.map((key, i) => ({
t: key,
mean: Math.round(value(i / starts.length, key) * 10) / 10,
count: key === SYS_THIN ? 2 : 4 + (i % 9),
}))
.filter((p) => !skip(p.t));
}
function series(dataFrom: string, today: string, bucket: Bucket) {
const r = rng(11);
return [
{
key: "BLOOD_PRESSURE_SYS",
unit: "mmHg",
points: points(
dataFrom,
today,
bucket,
(f) => 147 - 18 * f + (r() - 0.5) * 4,
(t) => bucket === "quarter" && SYS_MISSING.has(t),
),
},
{
key: "BLOOD_PRESSURE_DIA",
unit: "mmHg",
points: points(
dataFrom,
today,
bucket,
(f) => 92 - 10 * f + (r() - 0.5) * 3,
),
},
{
key: "WEIGHT",
unit: "kg",
points: points(
dataFrom,
today,
bucket,
(f) => 89 - 6.5 * f + (r() - 0.5) * 0.6,
),
},
{
key: "RESTING_HEART_RATE",
unit: "bpm",
points: points(dataFrom, today, bucket, (f, k) => {
const month = Number(k.slice(5, 7));
return (
68 - 5 * f + Math.sin((month / 12) * 6.28) * 1.2 + (r() - 0.5) * 1.2
);
}),
},
{
key: "PULSE",
unit: "bpm",
points: points(dataFrom, today, bucket, (f) => 74 - 4 * f + r()),
},
{
key: "BODY_FAT",
unit: "%",
points: points(dataFrom, today, bucket, (f) => 24 - 3 * f + r() * 0.4),
},
{
key: "ACTIVITY_STEPS",
unit: "steps",
points: points(
dataFrom,
today,
bucket,
(f) => 7200 + 900 * f + r() * 400,
),
},
];
}
/**
* Every series the `full` record can show: fifteen, past the five colours
* and past the six lines the chart used to stop at.
*/
export const FULL_AVAILABLE = [
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
"BODY_FAT",
"SLEEP_DURATION",
"ACTIVITY_STEPS",
"BLOOD_GLUCOSE",
"OXYGEN_SATURATION",
"HEART_RATE_VARIABILITY",
"RESTING_HEART_RATE",
"VO2_MAX",
"BODY_TEMPERATURE",
"RESPIRATORY_RATE",
"MOOD",
];
export function fullTimeline(today: string) {
const vaccinations = [
"2020-06-10",
"2021-05-20",
"2021-07-01",
"2021-12-10",
"2022-10-15",
"2023-10-20",
"2024-03-20",
"2024-04-25",
"2024-10-18",
"2025-10-10",
];
const visits = [
"2019-03-10",
"2020-03-05",
"2021-09-14",
"2022-09-20",
"2023-09-12",
"2024-09-17",
"2025-09-18",
"2026-01-03",
"2026-06-22",
];
const documents = [
"2019-03-12",
"2020-03-06",
"2021-09-15",
"2022-09-21",
"2022-11-09",
"2023-02-20",
"2023-09-13",
"2024-09-18",
"2025-03-14",
"2025-09-19",
"2026-01-03",
"2026-06-23",
];
return {
zoom: "all",
range: {
from: "2019-01-01",
to: `${today.slice(0, 4)}-12-31`,
dataFrom: "2019-01-01",
},
lanes: [
{
key: "life",
items: [
item({
id: "le-1",
kind: "lifeEvent",
start: "2021-05-14",
label: "Geburt Tochter",
}),
item({
id: "le-2",
kind: "lifeEvent",
start: "2023-09-01",
precision: "MONTH",
label: "Umzug",
}),
item({
id: "le-3",
kind: "lifeEvent",
start: "2024-10-01",
label: "Neue Stelle",
}),
],
},
{
key: "illness",
items: [
item({
id: "ill-c",
kind: "chronic",
start: "2019-03-10",
open: true,
label: "Bluthochdruck",
sub: "chronisch",
href: "/illness",
}),
item({
id: "ill-1",
kind: "episode",
start: "2020-09-01",
end: "2020-10-20",
label: "Schulter",
}),
item({
id: "ill-2",
kind: "episode",
start: "2022-03-08",
end: "2022-03-24",
label: "COVID-19",
}),
item({
id: "ill-3",
kind: "episode",
start: "2022-11-02",
end: "2023-02-15",
label: "Bandscheibe L4/L5",
}),
item({
id: "ill-4",
kind: "episode",
start: "2024-08-10",
end: "2024-08-16",
label: "Magen-Darm",
}),
item({
id: "ill-5",
kind: "episode",
start: "2025-12-31",
end: "2026-01-08",
label: "Erkältung",
}),
],
},
{
key: "allergies",
items: [
item({
id: "al-1",
kind: "allergy",
start: "2018-01-01",
open: true,
label: "Gräserpollen",
sub: "seit 2008",
}),
item({
id: "al-2",
kind: "allergy",
start: "2020-07-15",
open: true,
label: "Penicillin",
}),
],
},
{
key: "medications",
items: [
item({
id: "med-1",
group: "med-1",
kind: "medication",
start: "2019-04-02",
open: true,
label: "Ramipril",
sub: "2,5 mg",
}),
item({
id: "dose-1",
group: "med-1",
kind: "doseChange",
start: "2020-08-12",
label: "Ramipril",
sub: "5 mg",
}),
item({
id: "med-2",
group: "med-2",
kind: "medication",
start: "2022-11-03",
end: "2022-12-10",
label: "Ibuprofen",
}),
// One medication sent as its own span, a course, three dose
// changes and a pause: the case the beta drew as two medications.
item({
id: "mj",
group: "mj",
kind: "medication",
start: "2025-01-06",
open: true,
label: "Mounjaro",
sub: "7,5 mg",
}),
item({
id: "mj-course",
group: "mj",
kind: "course",
start: "2025-01-06",
open: true,
label: "Mounjaro",
}),
...(
[
["mj-25", "2025-01-06", "2,5 mg"],
["mj-5", "2025-03-03", "5 mg"],
["mj-75", "2025-06-02", "7,5 mg"],
] as const
).map(([id, start, sub]) =>
item({
id,
group: "mj",
kind: "doseChange",
start,
label: "Mounjaro",
sub,
}),
),
item({
id: "mj-pause",
group: "mj",
kind: "pause",
start: "2025-08-01",
end: "2025-08-20",
label: "Mounjaro",
}),
item({
id: "c-1",
group: "vit-d",
kind: "course",
start: "2023-11-01",
end: "2024-03-31",
label: "Vitamin D (Winter)",
}),
item({
id: "c-2",
group: "vit-d",
kind: "course",
start: "2024-11-01",
end: "2025-03-31",
label: "Vitamin D (Winter)",
}),
item({
id: "c-3",
group: "vit-d",
kind: "course",
start: "2025-11-01",
end: "2026-03-31",
label: "Vitamin D (Winter)",
}),
],
},
{
key: "vaccinations",
items: vaccinations.map((d, i) =>
item({
id: `vac-${i}`,
kind: "vaccination",
start: d,
label: "Impfung",
}),
),
},
{
key: "visits",
items: [
...visits.map((d, i) =>
item({
id: `v-${i}`,
kind: "visit",
start: d,
label: "Hausarztpraxis",
}),
),
item({
id: "p-1",
kind: "procedure",
start: "2022-11-08",
label: "MRT Lendenwirbelsäule",
}),
item({
id: "p-2",
kind: "procedure",
start: "2025-03-12",
label: "Darmspiegelung",
}),
],
},
{
key: "documents",
items: documents.map((d, i) =>
item({ id: `doc-${i}`, kind: "document", start: d, label: "Befund" }),
),
},
],
standing: [],
bucket: "quarter" as Bucket,
series: series("2019-01-01", today, "quarter"),
availableSeries: FULL_AVAILABLE,
notable: [{ date: "2026-01-04", kind: "extremeHigh" }],
};
}
export function readyTimeline(today: string) {
return {
zoom: "all",
range: {
from: "2024-01-01",
to: `${today.slice(0, 4)}-12-31`,
dataFrom: "2024-05-01",
},
lanes: [
{
key: "illness",
items: [
item({
id: "ill-c",
kind: "chronic",
start: "2019-03-10",
open: true,
label: "Bluthochdruck",
sub: "chronisch",
}),
item({
id: "ill-4",
kind: "episode",
start: "2024-08-10",
end: "2024-08-16",
label: "Magen-Darm",
}),
item({
id: "ill-5",
kind: "episode",
start: "2025-12-31",
end: "2026-01-08",
label: "Erkältung",
}),
],
},
{
key: "medications",
items: [
item({
id: "med-1",
group: "med-1",
kind: "medication",
start: "2024-05-01",
open: true,
startKnown: false,
label: "Ramipril",
sub: "5 mg",
}),
item({
id: "c-2",
group: "vit-d",
kind: "course",
start: "2024-11-01",
end: "2025-03-31",
label: "Vitamin D (Winter)",
}),
item({
id: "c-3",
group: "vit-d",
kind: "course",
start: "2025-11-01",
end: "2026-03-31",
label: "Vitamin D (Winter)",
}),
],
},
{
key: "visits",
items: [
item({
id: "v-1",
kind: "visit",
start: "2024-09-17",
label: "Hausarztpraxis",
}),
item({
id: "p-2",
kind: "procedure",
start: "2025-03-12",
label: "Darmspiegelung",
}),
item({
id: "v-2",
kind: "visit",
start: "2025-09-18",
label: "Hausarztpraxis",
}),
item({
id: "v-3",
kind: "visit",
start: "2026-01-03",
label: "Hausarztpraxis",
}),
item({
id: "v-4",
kind: "visit",
start: "2026-06-22",
label: "Hausarztpraxis",
}),
],
},
{
key: "documents",
items: [
"2024-09-18",
"2025-03-14",
"2025-09-19",
"2026-01-03",
"2026-06-23",
].map((d, i) =>
item({ id: `doc-${i}`, kind: "document", start: d, label: "Befund" }),
),
},
],
standing: [],
bucket: "quarter" as Bucket,
series: series("2024-05-01", today, "quarter").filter(
(s) => s.key !== "RESTING_HEART_RATE",
),
availableSeries: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "WEIGHT"],
notable: [],
};
}
export function fullReadiness() {
return {
verdict: "carries",
since: "2019-03-01",
lanes: [
{
key: "values",
status: "carries",
count: 3,
detail: { key: "values", params: { count: 3, since: "2019-03-01" } },
gaps: [],
},
{
key: "illness",
status: "carries",
count: 6,
detail: { key: "illness", params: { count: 6, chronic: 1 } },
gaps: [],
},
{
key: "visits",
status: "carries",
count: 11,
detail: { key: "visits", params: { count: 11, procedures: 2 } },
gaps: [],
},
{
key: "documents",
status: "carries",
count: 14,
detail: { key: "documents", params: { count: 14 } },
gaps: [],
},
{
key: "labs",
status: "carries",
count: 5,
detail: { key: "labs", params: { count: 5 } },
gaps: [],
},
{
key: "medications",
status: "thin",
count: 5,
detail: { key: "medications", params: { count: 5, missingStart: 3 } },
gaps: [
{ key: "medicationsWithoutStart", count: 3, href: "/medications" },
],
},
{
key: "vaccinations",
status: "empty",
count: 0,
detail: { key: "empty", params: {} },
gaps: [{ key: "vaccinationsEmpty", count: 0, href: "/vaccinations" }],
},
{
key: "life",
status: "empty",
count: 0,
detail: null,
gaps: [
{ key: "lifeEventsEmpty", count: 0, href: "/timeline?add=lifeEvent" },
],
},
],
};
}
export function readyReadiness() {
return {
verdict: "carries",
since: "2024-05-01",
lanes: [
{
key: "values",
status: "carries",
count: 2,
detail: { key: "values", params: { count: 2, since: "2024-05-01" } },
gaps: [],
},
{
key: "illness",
status: "carries",
count: 3,
detail: { key: "illness", params: { count: 3, chronic: 1 } },
gaps: [],
},
{
key: "visits",
status: "carries",
count: 5,
detail: { key: "visits", params: { count: 5, procedures: 1 } },
gaps: [],
},
{
key: "documents",
status: "carries",
count: 5,
detail: { key: "documents", params: { count: 5 } },
gaps: [],
},
{
key: "medications",
status: "thin",
count: 3,
detail: { key: "medications", params: { count: 3, missingStart: 3 } },
gaps: [
{ key: "medicationsWithoutStart", count: 3, href: "/medications" },
],
},
{
key: "life",
status: "empty",
count: 0,
detail: null,
gaps: [
{ key: "lifeEventsEmpty", count: 0, href: "/timeline?add=lifeEvent" },
],
},
{
key: "vaccinations",
status: "empty",
count: 0,
detail: { key: "empty", params: {} },
gaps: [{ key: "vaccinationsEmpty", count: 0, href: "/vaccinations" }],
},
],
};
}
const LIFE_EVENTS = [
{
id: "le-1",
category: "FAMILY",
startDate: "2021-05-14",
endDate: null,
precision: "DAY",
title: "Geburt Tochter",
note: null,
},
{
id: "le-2",
category: "HOME",
startDate: "2023-09-01",
endDate: null,
precision: "MONTH",
title: "Umzug",
note: null,
},
{
id: "le-3",
category: "WORK",
startDate: "2024-10-01",
endDate: null,
precision: "DAY",
title: "Neue Stelle",
note: null,
},
].map((e) => ({
...e,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
}));
function json(route: Route, status: number, data: unknown) {
return route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify({ data, error: null }),
});
}
export interface TimelineMockLog {
timelineQueries: URLSearchParams[];
lifeEventPosts: unknown[];
}
/**
* Answer the timeline routes from the `full` or `ready` record. Returns a
* log of what the page asked for and posted, for assertions.
*
* `onlyOld`: the record's entries all lie before the last year, so a
* quarter or year window answers empty (with the record's `dataFrom`) and
* only everything, or a chosen range, carries them.
*
* `dataFrom`: the record's first entry as the answers name it, to stand in
* for a young record.
*/
export async function mockTimeline(
page: Page,
state: "full" | "ready",
today: string,
options: { onlyOld?: boolean; dataFrom?: string } = {},
): Promise<TimelineMockLog> {
const log: TimelineMockLog = { timelineQueries: [], lifeEventPosts: [] };
await page.route(/\/api\/timeline\/readiness(\?|$)/, (route) =>
json(route, 200, state === "full" ? fullReadiness() : readyReadiness()),
);
await page.route(/\/api\/timeline(\?|$)/, (route) => {
const url = new URL(route.request().url());
log.timelineQueries.push(url.searchParams);
const body = state === "full" ? fullTimeline(today) : readyTimeline(today);
const values = (url.searchParams.get("values") ?? "")
.split(",")
.filter(Boolean);
const zoom = url.searchParams.get("zoom") ?? "all";
const from = url.searchParams.get("from");
const to = url.searchParams.get("to");
const bucket = bucketFor(zoom, from, to);
const dataFrom =
options.dataFrom ?? (state === "full" ? "2019-01-01" : "2024-05-01");
if (options.onlyOld && (zoom === "quarter" || zoom === "year")) {
return json(route, 200, {
...body,
zoom,
bucket,
range: { from, to, dataFrom },
lanes: body.lanes.map((lane) => ({ ...lane, items: [] })),
standing: [],
series: values.map((key) => ({ key, unit: null, points: [] })),
});
}
// A chosen range answers its own window, as the server does.
const chosen = zoom === "range" && from && to;
const all =
!chosen && bucket === body.bucket
? body.series
: series(chosen ? from : dataFrom, chosen ? to : today, bucket);
return json(route, 200, {
...body,
zoom,
bucket,
...(chosen
? { range: { from, to, dataFrom } }
: options.dataFrom
? { range: { ...body.range, dataFrom } }
: {}),
// Any other value the menu offers answers a flat line of its own.
series: values.map(
(key) =>
all.find((s) => s.key === key) ?? {
key,
unit: null,
points: points(dataFrom, today, bucket, (f) => 50 + 10 * f),
},
),
});
});
await page.route(/\/api\/life-events(\/[^/?]+)?(\?|$)/, async (route) => {
const method = route.request().method();
if (method === "GET") {
return json(route, 200, { events: state === "full" ? LIFE_EVENTS : [] });
}
if (method === "POST") {
const posted = route.request().postDataJSON() as Record<string, unknown>;
log.lifeEventPosts.push(posted);
return json(route, 201, {
id: "le-new",
endDate: null,
note: null,
...posted,
createdAt: "2026-10-01T00:00:00.000Z",
updatedAt: "2026-10-01T00:00:00.000Z",
});
}
return json(route, 200, { deleted: true });
});
return log;
}
+24 -4
View File
@@ -16,7 +16,7 @@ import {
const DOMAIN_READS = {
measurements: "/api/measurements",
medications: "/api/medications",
labs: "/api/labs",
labs: "/api/labs/overview",
profile: "/api/profile/summary",
illness: "/api/illness/episodes",
mind: "/api/mood-entries",
@@ -503,13 +503,20 @@ test.describe.serial("scoped sharing browser journeys", () => {
await expect(banner).toHaveAttribute("data-record-kind", "shared");
await page.goto("/measurements");
await expect(page.locator('[data-slot="measurement-add"]')).toBeVisible();
// An empty list carries its add action in the empty state rather than
// the header, so the write grant shows whichever of the two applies.
const addControl = page
.locator(
'[data-slot="measurement-add"], [data-slot="measurement-add-first"]',
)
.first();
await expect(addControl).toBeVisible();
const post = page.waitForResponse(
(response) =>
response.request().method() === "POST" &&
new URL(response.url()).pathname === "/api/measurements",
);
await page.locator('[data-slot="measurement-add"]').click();
await addControl.click();
await page.locator("#sys").fill("124");
await page.locator("#dia").fill("78");
await page.getByRole("button", { name: /^save$/i }).click();
@@ -621,8 +628,21 @@ test.describe.serial("scoped sharing browser journeys", () => {
);
await expect(integrations).toBeVisible();
await expect(integrations).toHaveAttribute("data-record-id", accountId);
// Read-only: the shared integration cards render, but nothing on them
// acts on a provider. The only buttons are each card's own "details for a
// bug report" toggle and its copy button.
await expect(
integrations.locator("button, input, select, textarea"),
integrations.locator('[data-slot="integration-card"]'),
).toHaveCount(8);
await expect(
integrations.locator(
'[data-slot="integration-primary-action"], [data-slot="integration-menu-trigger"], input, select, textarea',
),
).toHaveCount(0);
await expect(
integrations.locator(
'button:not([data-slot="integration-details-toggle"]):not([data-slot="integration-diagnostics-copy"])',
),
).toHaveCount(0);
await page.goBack();
+29 -3
View File
@@ -26,6 +26,7 @@
* the same ones, for the same reasons written there.
*/
import type { Page } from "@playwright/test";
import pg from "pg";
import { expect, test } from "./setup/test";
import {
@@ -111,15 +112,38 @@ test.describe.serial("a guardian's controls inside a managed profile", () => {
if (!managed) throw new Error("managed record fixture is missing");
test("offers the mood add control and lands the entry", async ({ page }) => {
// The profile starts every run without mood entries. The form stamps an
// entry to the minute and `(user, date, moodLoggedAt)` is unique, so a
// second run inside the same minute (a retry, a repeat) met a 409 for the
// entry the first one wrote. An empty record also fixes which control the
// page offers: the empty state's add, not the header's.
const dbUrl = process.env.DATABASE_URL;
test.skip(!dbUrl, "DATABASE_URL is required to reset the profile's mood");
const pool = new pg.Pool({ connectionString: dbUrl });
try {
await pool.query(
`DELETE FROM mood_entries
WHERE user_id = (SELECT id FROM users WHERE username = $1)`,
[managed.username],
);
} finally {
await pool.end();
}
await openRecord(page, managed.username);
const banner = page.locator('[data-slot="shared-record-banner"]');
await expect(banner).toHaveAttribute("data-record-kind", "managed");
await expect(banner).toHaveAttribute("data-access-level", "manage");
await page.goto("/mood");
const add = page.locator('[data-slot="mood-add-entry"]');
await expect(add).toBeVisible({ timeout: 30_000 });
await add.click();
// Since v1.42 an empty list carries the one add control in its empty
// state and the header drops its own; the header's returns once there is
// an entry to list. Both are asserted on their data-slots.
const addFirst = page.locator('[data-slot="mood-add-first"]');
const addHeader = page.locator('[data-slot="mood-add-entry"]');
await expect(addFirst).toBeVisible({ timeout: 30_000 });
await expect(addHeader).toHaveCount(0);
await addFirst.click();
const face = page.locator('[data-slot="mood-face"][data-mood="GUT"]');
await expect(face).toBeVisible();
@@ -139,6 +163,8 @@ test.describe.serial("a guardian's controls inside a managed profile", () => {
await expect(page.locator('[data-slot="mood-rows"]').first()).toBeVisible({
timeout: 30_000,
});
await expect(addHeader).toBeVisible();
await expect(addFirst).toHaveCount(0);
await leaveRecord(page);
});
+258
View File
@@ -0,0 +1,258 @@
/**
* v1.42 (#959) — a managed profile handed over to the person it describes,
* end to end in a browser: the Guardian mints the link, the person opens it in
* a browser that has never signed in, chooses a sign-in, lands on the decision
* about their former Guardian, and decides.
*
* The link is never mailed (by design: it is shown once and handed over in
* person or scanned). This journey "mails" it by reading it off the panel's
* `data-handover-url` and opening it in a second, cookie-less browser context,
* which is exactly the hop a person makes with a phone.
*
* Its own Guardian account, per project, seeded here: the shared
* `e2e-guardian` belongs to the managed-profile journey, which clears every
* profile that account looks after, and the two would delete each other's
* records mid-run. Every assertion addresses a stable `data-*` attribute or
* the database, never rendered copy.
*/
import AxeBuilder from "@axe-core/playwright";
import { hash } from "@node-rs/argon2";
import type { Page } from "@playwright/test";
import pg from "pg";
import { expect, test } from "./setup/test";
const PASSWORD = "Handover!Journey-2042";
function connect(): pg.Pool {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("[handover-e2e] DATABASE_URL is not set");
return new pg.Pool({ connectionString: url });
}
/** WCAG 2.1 AA on the screen as it stands. */
async function expectNoAxeViolations(page: Page): Promise<void> {
const result = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(result.violations).toEqual([]);
}
function names(project: string) {
const slug = project
.replace(/[^a-z0-9]/gi, "")
.toLowerCase()
.slice(0, 12);
return {
guardian: `e2e-hg-${slug}`,
claimant: `e2e-hc-${slug}`,
};
}
/**
* A Guardian with a password, signed in through the real login route, then
* given a second factor and a session that has just proved it — the state
* `requireFreshMfa` needs and the only state in which a handover can be made.
*/
async function seedGuardian(
pool: pg.Pool,
username: string,
claimant: string,
): Promise<void> {
// Whatever a previous run left: the claimed account (no longer a managed
// profile, so it does not cascade with the guardian) and the guardian.
await pool.query(`DELETE FROM users WHERE username = $1 OR email = $2`, [
claimant,
`${claimant}@healthlog.test`,
]);
await pool.query(
`DELETE FROM users WHERE managed_profile_at IS NOT NULL AND id IN (
SELECT g.grantor_id FROM account_grants g JOIN users u ON u.id = g.grantee_id
WHERE u.username = $1)`,
[username],
);
await pool.query(`DELETE FROM users WHERE username = $1`, [username]);
const passwordHash = await hash(PASSWORD, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
});
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role, created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed,
disclaimer_acknowledged_at, disclaimer_acknowledged_version)
VALUES ($1, $2, $3, $4, 'USER', NOW(), NOW(), NOW(), true, NOW(), '1')`,
[
`c${Date.now().toString(36)}${username}`,
username,
`${username}@healthlog.test`,
passwordHash,
],
);
// The claim and login buckets for this address, so a local re-run inside the
// window meets the product rather than its rate limit.
await pool.query(
`DELETE FROM rate_limits WHERE key LIKE 'auth:claim%' OR key LIKE 'managed-profile:%'`,
);
}
async function stampSecondFactor(pool: pg.Pool, username: string) {
await pool.query(
`UPDATE users SET totp_confirmed_at = NOW() WHERE username = $1`,
[username],
);
await pool.query(
`UPDATE sessions SET mfa_verified_at = NOW()
WHERE user_id = (SELECT id FROM users WHERE username = $1)`,
[username],
);
}
test.describe("managed profile handover", () => {
test("guardian hands over, the person claims and decides", async ({
page,
browser,
baseURL,
}, testInfo) => {
test.setTimeout(120_000);
const { guardian, claimant } = names(testInfo.project.name);
const pool = connect();
try {
await seedGuardian(pool, guardian, claimant);
// ── The Guardian ─────────────────────────────────────────────────
const login = await page.request.post("/api/auth/login", {
data: { email: guardian, password: PASSWORD },
});
expect(login.status()).toBe(200);
await stampSecondFactor(pool, guardian);
const created = await page.request.post("/api/managed-profiles", {
data: {
displayName: "Robin",
dateOfBirth: "2012-03-04",
locale: "en",
timezone: "Europe/Berlin",
gender: null,
},
});
expect(created.status()).toBe(201);
const profileId = ((await created.json()) as { data: { id: string } })
.data.id;
await page.goto("/settings/access");
const row = page.locator(
`[data-slot="managed-profile-row"][data-managed-profile-id="${profileId}"]`,
);
await expect(row).toBeVisible();
await row
.locator('[data-slot="managed-profile-handover-toggle"]')
.click();
const form = row.locator('[data-slot="managed-profile-handover-form"]');
await expect(form).toBeVisible();
// Born 2012: the minor hint is shown, the choice stays the Guardian's.
await expect(
row.locator('[data-slot="managed-profile-handover-minor"]'),
).toBeVisible();
// The Guardian proposes to keep managing.
await form
.locator('[data-slot="managed-profile-handover-proposal"]')
.first()
.selectOption("manage");
await form
.locator('[data-slot="managed-profile-handover-create"]')
.click();
const linkPanel = row.locator(
'[data-slot="managed-profile-handover-link"]',
);
await expect(linkPanel).toBeVisible();
await expect(
row.locator('[data-slot="managed-profile-handover-qr"]'),
).toBeVisible();
const url = await linkPanel.getAttribute("data-handover-url");
expect(url).toMatch(/\/claim\/hlp_[0-9a-f]{64}$/);
await expectNoAxeViolations(page);
// ── The person, on a browser that never signed in ────────────────
const person = await browser.newContext({ baseURL });
try {
const claimPage = await person.newPage();
const path = new URL(url as string).pathname;
await claimPage.goto(path);
await expect(claimPage).toHaveURL(/\/auth\/claim\?token=hlp_/);
await expect(
claimPage.locator('[data-testid="claim-form-card"]'),
).toBeVisible();
await expect(
claimPage.locator(
'[data-slot="claim-guardian"][data-proposal="manage"]',
),
).toHaveCount(1);
await expectNoAxeViolations(claimPage);
await claimPage.locator("#claim-username").fill(claimant);
await claimPage
.locator("#claim-email")
.fill(`${claimant}@healthlog.test`);
await claimPage.locator("#claim-password").fill(PASSWORD);
await claimPage.locator('[data-slot="claim-submit"]').click();
await expect(claimPage).toHaveURL(/\/onboarding\/handover$/, {
timeout: 30_000,
});
const decision = claimPage.locator(
'[data-slot="handover-decision-guardian"][data-decidable="true"]',
);
await expect(decision).toHaveCount(1);
await expect(
claimPage.locator('[data-slot="handover-notification-hint"]'),
).toBeVisible();
await expectNoAxeViolations(claimPage);
// The person decides: view only, not managing.
await decision
.locator('[data-slot="handover-decision-choice"]')
.selectOption("read");
await claimPage
.locator('[data-slot="handover-decision-submit"]')
.click();
await expect(claimPage).toHaveURL(/\/onboarding(\?|$)/, {
timeout: 30_000,
});
// The used link is dead for anyone after.
const reused = await person.request.post("/api/auth/claim/preview", {
data: { token: (url as string).split("/claim/")[1] },
});
// A signed-in browser is refused before the token is read; a fresh one
// would get the uniform 404. Either way, nothing is handed over twice.
expect([404, 409]).toContain(reused.status());
} finally {
await person.close();
}
// ── What the database says ───────────────────────────────────────
const account = await pool.query(
`SELECT managed_profile_at, username, email, password_hash IS NOT NULL AS has_password
FROM users WHERE id = $1`,
[profileId],
);
expect(account.rows[0]).toMatchObject({
managed_profile_at: null,
username: claimant,
has_password: true,
});
const live = await pool.query(
`SELECT g.access FROM account_grants g JOIN users u ON u.id = g.grantee_id
WHERE g.grantor_id = $1 AND u.username = $2 AND g.revoked_at IS NULL`,
[profileId, guardian],
);
expect(live.rows).toEqual([{ access: "READ" }]);
} finally {
await pool.end();
}
});
});
+384
View File
@@ -0,0 +1,384 @@
/**
* v1.42 — three surfaces made easier to live with.
*
* - Documents: the display choice behind the wrench (cards or list, months
* stacked or flowing) persists per account; every delete asks first; the
* selection is cleared with an X on the count line.
* - Wellness scores: every score tile shows its course; a score's detail
* page carries its history right under the score card and no longer the
* warning-coloured "not clinical" line.
* - Mood insights: the calendar and the line chart appear together without
* moving the page, and logging is the header plus every Insights page
* uses.
*
* Everything is addressed through stable data attributes, never copy.
*/
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { ensureVaultFixture, seedNamespaceDocs } from "./setup/vault-fixture";
import { mockPopulatedInsights } from "./utils/mock-populated-insights";
test.use({ storageState: STORAGE_STATE_PATH });
async function resetDocumentsLayout(page: Page): Promise<void> {
const res = await page.request.put("/api/documents/inbound/layout", {
data: { version: 1, view: "cards", arrangement: "stacked" },
});
expect(res.status()).toBe(200);
}
test.describe("documents", () => {
test.beforeAll(async () => {
await ensureVaultFixture();
});
test("the display choice behind the wrench persists across visits", async ({
page,
isMobile,
}) => {
// Both projects share one account; the layout is account state, so one
// project owns this journey.
test.skip(isMobile, "account-level preference; one project owns it");
await resetDocumentsLayout(page);
try {
await page.goto("/documents");
const timeline = page.locator('[data-slot="document-timeline"]');
await expect(timeline).toHaveAttribute("data-view", "cards");
await expect(timeline).toHaveAttribute("data-arrangement", "stacked");
await page.locator('[data-slot="documents-customize"]').click();
await expect(page).toHaveURL(/\/settings\/layout\/documents$/);
const settings = page.locator('[data-slot="documents-layout-settings"]');
await settings.locator('[data-slot="module-view-list"]').click();
await expect(
settings.locator('[data-slot="module-view-list"]'),
).toHaveAttribute("aria-pressed", "true");
const flow = settings.locator('[data-slot="documents-arrangement-flow"]');
// Wait for the write to land, so the reload below reads it back.
const saved = page.waitForResponse(
(r) =>
r.url().includes("/api/documents/inbound/layout") &&
r.request().method() === "PUT",
);
await flow.click();
expect((await saved).status()).toBe(200);
await page.goto("/documents");
await expect(timeline).toHaveAttribute("data-view", "list");
await expect(timeline).toHaveAttribute("data-arrangement", "flow");
await expect(
timeline
.locator('[data-slot="document-card"][data-variant="row"]')
.first(),
).toBeVisible();
// Flowing: no month gets a row of its own, the name rides inline.
await expect(
timeline.locator('[data-slot="document-flow-month"]').first(),
).toBeVisible();
// A fresh visit in a fresh page — the choice is the account's, not
// this tab's.
const second = await page.context().newPage();
await second.goto("/documents");
await expect(
second.locator('[data-slot="document-timeline"]'),
).toHaveAttribute("data-arrangement", "flow");
await second.close();
} finally {
await resetDocumentsLayout(page);
}
});
test("bulk delete asks first, and cancelling deletes nothing", async ({
page,
isMobile,
}) => {
test.skip(isMobile, "selection by checkbox is a pointer flow");
await seedNamespaceDocs("qoldel", 2);
await page.goto("/documents?q=qoldel");
const box = page.getByRole("checkbox", { name: "Select qoldel 002" });
await expect(box).toBeVisible();
await box.click();
let deletes = 0;
page.on("request", (req) => {
if (
req.method() === "POST" &&
req.url().includes("/api/documents/inbound/bulk") &&
(req.postData() ?? "").includes('"delete"')
) {
deletes += 1;
}
});
const bar = page.locator('[data-slot="document-bulk-bar"]');
await bar.locator('[data-slot="document-bulk-delete"]').click();
const confirm = page.locator('[data-slot="documents-delete-confirm"]');
await expect(confirm).toBeVisible();
await page
.getByRole("alertdialog")
.getByRole("button", { name: "Cancel" })
.click();
await expect(confirm).toBeHidden();
expect(deletes).toBe(0);
await expect(
page.getByRole("button", { name: "Open qoldel 002" }),
).toBeVisible();
await bar.locator('[data-slot="document-bulk-delete"]').click();
await confirm.click();
await expect(
page.getByRole("button", { name: "Open qoldel 002" }),
).toBeHidden();
expect(deletes).toBe(1);
});
test("the Delete key on a focused card asks first too", async ({
page,
isMobile,
}) => {
test.skip(isMobile, "keyboard flow");
await seedNamespaceDocs("qolkey", 1);
await page.goto("/documents?q=qolkey");
const open = page.getByRole("button", { name: "Open qolkey 001" });
await expect(open).toBeVisible();
await open.focus();
await page.keyboard.press("Delete");
await expect(
page.locator('[data-slot="documents-delete-confirm"]'),
).toBeVisible();
await page.keyboard.press("Escape");
await expect(open).toBeVisible();
});
test("the selection clears with the X on the count line", async ({
page,
isMobile,
}) => {
test.skip(isMobile, "selection by checkbox is a pointer flow");
await seedNamespaceDocs("qolclear", 2);
await page.goto("/documents?q=qolclear");
await page.getByRole("checkbox", { name: "Select qolclear 001" }).click();
const bar = page.locator('[data-slot="document-bulk-bar"]');
await expect(bar).toBeVisible();
const count = bar.locator('[data-slot="document-bulk-count"]');
const clear = bar.locator('[data-slot="document-bulk-clear"]');
const actions = bar.locator('[data-slot="document-bulk-actions"]');
// The X sits on the count line, at its right edge, above the action row,
// and the action row follows the count line without a gap of its own.
const [countBox, clearBox, actionsBox, barBox] = await Promise.all([
count.boundingBox(),
clear.boundingBox(),
actions.boundingBox(),
bar.boundingBox(),
]);
expect(countBox && clearBox && actionsBox && barBox).toBeTruthy();
const mid = (b: { y: number; height: number }) => b.y + b.height / 2;
expect(Math.abs(mid(clearBox!) - mid(countBox!))).toBeLessThan(6);
expect(
barBox!.x + barBox!.width - (clearBox!.x + clearBox!.width),
).toBeLessThan(20);
expect(
actionsBox!.y - (clearBox!.y + clearBox!.height),
).toBeLessThanOrEqual(10);
// One action row: every verb shares a top edge.
const tops = await actions
.locator("button")
.evaluateAll((els) =>
els.map((el) => Math.round(el.getBoundingClientRect().top)),
);
expect(new Set(tops).size).toBe(1);
await clear.click();
await expect(bar).toBeHidden();
});
});
test.describe("wellness scores", () => {
test("every score tile shows its course", async ({ page }) => {
await mockPopulatedInsights(page);
await page.goto("/insights");
for (const metric of ["RECOVERY_SCORE", "STRESS_SCORE"]) {
const tile = page.locator(
`[data-slot="wellness-score-tile"][data-metric="${metric}"]`,
);
await tile.scrollIntoViewIfNeeded();
await expect(
tile.locator('[data-slot="wellness-score-history"]'),
).toBeVisible();
}
});
test("a score's detail page carries its history under the card, without the caveat line", async ({
page,
}) => {
// The e2e account has no nightly score; serve one so the page has a
// score to draw the history under.
await page.route(
/\/api\/insights\/derived\?metric=RECOVERY_SCORE/,
(route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
metric: "RECOVERY_SCORE",
status: "ok",
value: {
score: 66,
band: "yellow",
trendDelta: 2,
daysInWindow: 5,
asOf: new Date().toISOString(),
series: [58, 61, 64, 60, 66],
anchor: null,
components: null,
},
coverage: {
requiredInputs: 1,
presentInputs: 1,
historyDays: 5,
missing: [],
},
confidence: { score: 80, band: "high" },
provenance: {
inputs: ["RECOVERY_SCORE"],
source: "live",
windowDays: 14,
computedAt: new Date().toISOString(),
},
reason: null,
assessment: null,
},
error: null,
}),
}),
);
await page.goto("/insights/scores/recovery");
const anatomy = page.locator('[data-slot="score-anatomy-view"]');
await expect(anatomy).toBeVisible();
const history = page.locator('[data-slot="score-history-chart"]');
await expect(history).toBeAttached();
const [cardBox, historyBox] = await Promise.all([
anatomy.boundingBox(),
history.boundingBox(),
]);
expect(historyBox!.y).toBeGreaterThan(cardBox!.y + cardBox!.height - 1);
await expect(anatomy.locator(".text-warning")).toHaveCount(0);
});
});
test.describe("mood insights", () => {
test.beforeAll(async ({ browser }) => {
// A short, real history, so the calendar and the chart both have days.
const context = await browser.newContext({
storageState: STORAGE_STATE_PATH,
});
const now = Date.now();
for (let day = 1; day <= 12; day++) {
const res = await context.request.post("/api/mood-entries", {
data: {
mood: day % 3 === 0 ? "OKAY" : "GUT",
moodLoggedAt: new Date(now - day * 86_400_000).toISOString(),
source: "MANUAL",
externalId: `e2e-qol-mood-${day}`,
},
});
expect([200, 201]).toContain(res.status());
}
await context.close();
});
test("the calendar and the chart appear together without moving the page", async ({
page,
}) => {
// The calendar's read is the slower one in production; slow it down here
// so the old order (chart first, calendar pushing it down) would show.
await page.route(/\/api\/mood\/insights(\?|$)/, async (route) => {
await new Promise((resolve) => setTimeout(resolve, 1_200));
await route.continue();
});
await page.addInitScript(() => {
const w = window as unknown as {
__cls: number;
__seen: Record<string, number>;
};
w.__cls = 0;
w.__seen = {};
new PerformanceObserver((list) => {
for (const entry of list.getEntries() as Array<
PerformanceEntry & { value: number; hadRecentInput: boolean }
>) {
if (!entry.hadRecentInput) w.__cls += entry.value;
}
}).observe({ type: "layout-shift", buffered: true });
const watch = () => {
if (
!w.__seen.heatmap &&
document.querySelector(
'[data-slot="mood-heatmap-body"] svg[role="img"]',
)
)
w.__seen.heatmap = performance.now();
if (
!w.__seen.chart &&
document.querySelector(
'[data-slot="insights-mood-chart"] .recharts-surface',
) &&
!document.querySelector(
'[data-slot="insights-mood-chart"] .invisible',
)
)
w.__seen.chart = performance.now();
requestAnimationFrame(watch);
};
requestAnimationFrame(watch);
});
await page.goto("/insights/mood");
await expect(
page.locator('[data-slot="mood-heatmap-body"] svg[role="img"]'),
).toBeAttached({
timeout: 15_000,
});
await expect(
page
.locator('[data-slot="insights-mood-chart"] .recharts-surface')
.first(),
).toBeVisible();
await page.waitForTimeout(1_500);
const { cls, seen } = await page.evaluate(() => {
const w = window as unknown as {
__cls: number;
__seen: Record<string, number>;
};
return { cls: w.__cls, seen: w.__seen };
});
expect(cls).toBeLessThan(0.02);
expect(seen.heatmap).toBeDefined();
expect(seen.chart).toBeDefined();
// Together: within a couple of frames of each other.
expect(Math.abs(seen.heatmap - seen.chart)).toBeLessThan(100);
});
test("logging is the header plus, not a text link", async ({ page }) => {
await page.goto("/insights/mood");
const add = page.locator(
'[data-slot="insights-mood-add"][data-header-add]',
);
await expect(add).toBeVisible();
await expect(
page.locator('[data-slot="insights-mood-log-link"]'),
).toHaveCount(0);
await expect(
page.locator('[data-slot="insights-mood-mental-wellbeing-link"]'),
).toHaveCount(0);
await add.click();
await expect(page.getByRole("dialog")).toBeVisible();
});
});
Loaded 100 of 2287 files, more files were not shown because too many files have changed in this diff. Show more