Merge pull request #945 from MBombeck/release/v1.38.14

v1.38.14 — the reported bugs at their class, and a gate that stops crying wolf
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-09-10 03:43:31 +02:00
commit 297395372b
107 files changed
+6758 -543

No files matched your search

+9 -1
View File
@@ -207,12 +207,20 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# the backup silently. The backup runs at 02:30 Europe/Berlin via pg-boss cron.
# Restore with `pnpm tsx scripts/restore-backup.ts <userId> <YYYY-MM-DD>`.
# See docs/ops/backup-restore.md for the format spec + step-by-step.
#
# The worker never deletes a BACKUP object -- its grant is PutObject, GetObject
# and AbortMultipartUpload -- so BACKUP_RETENTION_DAYS is the number you set
# the bucket's own lifecycle rule to, not something the worker enforces. The
# one key it does delete is its own connection test's `_healthcheck/<ts>.bin`,
# and that delete is best-effort: without DeleteObject on that prefix every
# Test-connection press leaves one 1-byte orphan behind, silently.
#
# BACKUP_S3_ENDPOINT="https://<account>.r2.cloudflarestorage.com"
# BACKUP_S3_BUCKET="healthlog-backups"
# BACKUP_S3_ACCESS_KEY=""
# BACKUP_S3_SECRET_KEY=""
# BACKUP_S3_REGION="auto" # "auto" for Cloudflare R2; real region for AWS S3
# BACKUP_RETENTION_DAYS="30" # objects older than this are pruned (best-effort; prefer a bucket lifecycle rule)
# BACKUP_RETENTION_DAYS="30" # the number for your bucket lifecycle rule (see above)
# BACKUP_ENCRYPTION_KEY="" # 64-hex-char or base64 32-byte; SEPARATE from ENCRYPTION_KEY
+17
View File
@@ -306,6 +306,23 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# BACKUP_S3_SECRET_KEY=""
# BACKUP_ENCRYPTION_KEY=""
# The two optional members of the set, both with defaults, so leaving them
# unset does not disable backups.
#
# Region sent with the SigV4 signature. Default "auto", which is what
# Cloudflare R2 expects; AWS S3 and most MinIO deployments want their real
# region name here.
# BACKUP_S3_REGION="auto"
#
# How long backup objects are meant to live, in days. Default 30, minimum 1.
# The worker never deletes a BACKUP object -- its grant is PutObject, GetObject
# and AbortMultipartUpload -- so this number is what you set the bucket's own
# lifecycle rule to. The one key it does delete is its own connection test's
# `_healthcheck/<ts>.bin`, and that delete is best-effort: without DeleteObject
# on that prefix every Test-connection press leaves one 1-byte orphan behind,
# silently. See docs/ops/backup-restore.md.
# BACKUP_RETENTION_DAYS="30"
# -----------------------------------------------------------------------------
# Audit-trail retention -- optional
@@ -31,6 +31,23 @@ jobs:
- run: pnpm db:generate
# The hero-geometry check in the unit suite renders the real panel in a
# real browser and measures the boxes, and it refuses to skip when CI is
# set — a check that skips measures nothing. This job runs the same suite
# as the quality job in `security.yml` and needs the same browser: without
# it every Dependabot pull request went red on a launch failure that said
# nothing about the bump (four of them on 2026-09-06 alone). Headless
# shell only, matching that job.
- name: Cache Playwright headless shell
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: playwright-shell-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
playwright-shell-${{ runner.os }}-
- run: pnpm exec playwright install --only-shell chromium
- run: pnpm test
# Auto-merge is gated on the Dependabot-declared update type:
+23
View File
@@ -5,6 +5,11 @@ on:
branches: [main]
pull_request:
branches: [main]
schedule:
# Daily, 05:47 UTC. Carries the dependency audit only — every other job
# here answers "is this change safe" and has nothing to say about a tree
# nobody touched. See the note on `dependency-audit`.
- cron: "47 5 * * *"
permissions:
contents: read
@@ -13,6 +18,7 @@ jobs:
quality:
name: Lint, Typecheck & Test
runs-on: ubuntu-latest
if: github.event_name != 'schedule'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -81,9 +87,25 @@ jobs:
fi
echo "::notice::OpenAPI spec in sync with Zod source schemas."
# Not on pull requests. `pnpm audit` answers a question about the published
# advisory database, not about the diff: the same lockfile passes on Monday
# and fails on Tuesday because someone else published, and the branch that
# goes red changed nothing. Seventeen of the forty-one failures this workflow
# produced in thirty days were exactly that, and a gate that fails for
# reasons the author cannot act on is a gate people learn to re-run rather
# than read. It runs on every push to `main` and once a day on the schedule
# above, which is the cadence the question actually has. What ships is still
# gated: `docker-security` scans the built image on every push to `main`, and
# `trivy.yml` scans the lockfile (dev dependencies included) and the
# published image daily.
dependency-audit:
name: Dependency Audit
runs-on: ubuntu-latest
# 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
# cut from a main whose push run is green.
continue-on-error: ${{ github.event_name == 'pull_request' }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -109,6 +131,7 @@ jobs:
secret-scan:
name: Secret Scanning
runs-on: ubuntu-latest
if: github.event_name != 'schedule'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+73
View File
@@ -0,0 +1,73 @@
# Synthetic post-deploy journey.
#
# `post-publish-verify.yml` proves the published image boots. This one
# proves that on a running instance a person can sign in, write a reading
# and read it back, and that the instance serves the version that was just
# published.
#
# Deliberately manual for now: the maintainer picks the target instance and
# creates the two repository secrets. Nothing calls this from the deploy
# path yet.
#
# Secrets, both required:
# SYNTHETIC_USERNAME — the probe account's username or email
# SYNTHETIC_PASSWORD — its password
#
# The probe account must be password-only (the password flow stops at the
# MFA ticket, which the script reports as a configuration failure), and the
# target must not be the demo instance, where every mutation is blocked.
name: Synthetic journey
on:
workflow_dispatch:
inputs:
# A closed list, not free text. The dispatcher chooses where the probe
# account's password is POSTed, so the choice is between the hosted
# instances and nothing else. The script re-checks the host against its
# own const allowlist and exits 2 on anything else, so a dispatch that
# goes round this form still cannot reach a stranger's collector.
base_url:
description: Instance to walk
required: true
type: choice
default: https://review.healthlog.dev
options:
- https://review.healthlog.dev
- https://healthlog.bombeck.io
expected_version:
description: Version the instance must serve, e.g. v1.38.13
required: true
type: string
permissions:
contents: read
jobs:
journey:
name: Sign in, write a reading, read it back
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
# A check that cannot fail is worse than none. The self-test walks the
# journey against an in-process instance once per leg with that leg
# broken, and fails if any of them still comes back green.
- name: Prove the check can go red
run: node scripts/synthetic-journey.mjs --self-test
# Every input and secret rides an env var — never interpolated into
# the shell command. The script refuses a `BASE_URL` whose host is off
# its allowlist before it sends the password anywhere.
- name: Walk the journey
env:
BASE_URL: ${{ inputs.base_url }}
EXPECTED_VERSION: ${{ inputs.expected_version }}
SYNTHETIC_USERNAME: ${{ secrets.SYNTHETIC_USERNAME }}
SYNTHETIC_PASSWORD: ${{ secrets.SYNTHETIC_PASSWORD }}
run: node scripts/synthetic-journey.mjs
+1 -1
View File
@@ -4,7 +4,7 @@ name: Trivy Scheduled Scan
#
# What already exists and is NOT repeated here:
# - `security.yml` → `dependency-audit` runs `pnpm audit --prod` on every
# push and pull request, blocking on HIGH/CRITICAL.
# push to `main` and once a day, blocking on HIGH/CRITICAL.
# - `security.yml` → `docker-security` builds the image on a push to `main`
# and runs `trivy image` against it, blocking on HIGH/CRITICAL.
#
+92
View File
@@ -1,5 +1,97 @@
# Changelog
## [1.38.14] — 2026-09-09
Two reported bugs fixed at their class, and the release gate stops crying
wolf. Both reports came from @mbreitkreuz, each traced to the line (#943,
#944).
### Fixed
- **Walking distance from an `export.zip` import was stored a thousand times
too small (#944).** Apple stamps every record in the archive with the
account's own display unit, and the import read the number without ever
reading that unit, so a 2.5 km day landed as 2.5 metres. HealthKit units
now convert through one shared table (`src/lib/measurements/hk-units.ts`),
the same conversion the workout import already used, which also caught a
workout archived with its energy in kilojoules. Every mapping entry that
keeps a fixed unit carries a written reason, enforced by a structural test.
Re-importing the archive repairs stored rows; for accounts without their
archive, `scripts/repair-apple-health-distance.ts` repairs exactly the rows
the export path wrote, dry-run by default, once per account. Runbook under
`docs/ops/apple-health-distance-repair.md`.
- **Blood glucose readings without a meal-time tag now count everywhere
(#943).** A meter synced through Apple Health writes no meal-time metadata,
so an account could hold hundreds of readings and see no Blood Glucose
tile, no glucose target and an empty glucose panel in the doctor report,
with the module on and no warning anywhere. Untagged readings are a bucket
of their own (`UNSPECIFIED`): they light the tile, carry its value and
trend, get a target card judged against the random-reading band, and reach
the report and the FHIR export. Tagged accounts keep their per-context
breakdown. A structural test refuses a dashboard tile gated on an enum
without an arm for the untagged case.
- **Four end-to-end specs that failed through two retries had real causes.**
`a11y` recognised "the chart" by a library class every chart shares (now a
`data-slot`); `documents` maximised the drawer before the vault had booked
the Coach hand-off; `vaccinations` and the record-session fence spec shared
a socket-reuse race in Playwright's keep-alive agent against the server's
five-second idle timeout (seeds now go through the page's own `fetch`).
- **Three unit specs had a teardown race that was a defect.** The score
tests' fake database was bypassed by a tail call to the real client, which
on a runner without Postgres failed after the test had ended and closed the
worker mid-write. The registration is wrapped, the settle guard covers it,
and the specs run in 20 ms instead of 700.
- **The first-run profile step no longer throws away what the server told
it.** A height outside the accepted range, or a date the server would not
take, was dropped on save while the wizard moved on and marked setup
finished; the value was gone and nothing said so. A refused field now says
why underneath the input that holds it, in the interface language, and the
step waits. The same applies to the profile card in Settings. A structural
test freezes every submitter of the profile route to that behaviour. The
iOS app has the same gap and is tracked there.
- **A retried medication write no longer lands twice.** Adding a medication,
logging a side effect and registering a supply container now honour
`Idempotency-Key`: a replay after a lost response returns the first answer
with `X-Idempotent-Replay: true` instead of creating a second row, which is
what the native client's offline queue relies on. The API contract says so.
### Changed
- **Release gate hygiene.** Bundle budgets carry a measured baseline per route
and 20 KB of headroom instead of one; the rate-limit integration test no
longer expects four database round trips inside 50 ms; the geometry checks
fail rather than skip when no browser is present, and the dependency-bump
workflow installs one; the dependency audit runs on pushes to `main` and
daily instead of failing pull requests over advisories that changed
nothing.
- **A synthetic journey after a deploy.** `scripts/synthetic-journey.mjs`
warms an instance, checks `/api/version` against the tag just published,
signs in, writes a reading with an idempotency key, reads it back and
deletes it, one line per leg; `.github/workflows/synthetic-journey.yml`
runs it by hand against a chosen instance. `docs/ops/deploy.md` says how and
what a red leg means.
- **Two more journeys in the gate.** Blood glucose in both units through the
capture form, the unit switch and the dashboard tiles; an Apple Health
`export.zip` through the import card to the measurements list with units
asserted. Both carry a skipped assertion that turns on with this release's
fixes, and both were broken deliberately once to prove they can fail.
- **`POST /api/nightscout/connect` is documented.** It sat in the route
coverage guard as an OAuth hand-off, which it is not; the native client's
reconciliation now finds it.
- **Off-host backup variables documented.** `BACKUP_S3_REGION` and
`BACKUP_RETENTION_DAYS` are in `.env.production.example`, and the runbook no
longer claims the worker prunes old objects: it never deletes, the bucket
lifecycle rule does.
## [1.38.13] — 2026-09-09
A guardian acting on a managed profile gets the add, edit and delete
+7 -6
View File
@@ -1,11 +1,12 @@
{
"$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 \u2014 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 \u2014 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.",
"routesKbGz": {
"/page": 460,
"/insights/page": 445,
"/measurements/page": 445,
"/insights/mood/page": 460
"$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": 447,
"/insights/page": 434,
"/measurements/page": 433,
"/insights/mood/page": 447
},
"routeDriftAllowanceKbGz": 20,
"totalClientKbGz": 3700,
"maxRechartsChunks": 1
}
+112 -4
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.38.13
version: 1.38.14
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -4834,6 +4834,7 @@ paths:
schema:
type: string
required: true
- *a6
requestBody:
required: true
content:
@@ -4855,6 +4856,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"409": *a7
"422": *a4
"429":
description: More than 30 side-effect writes in the trailing minute for this ACTOR.
@@ -5213,6 +5215,8 @@ paths:
medications.mirror.limit_exceeded`) once the caller already holds 30 medications mirrored from the same external
source; an idempotent re-post of a medication already mirrored is unaffected, and native creates are never
gated.
parameters:
- *a6
requestBody:
required: true
content:
@@ -5228,6 +5232,7 @@ paths:
$ref: "#/components/schemas/CreateMedicationResponse"
"401": *a1
"403": *a8
"409": *a7
"422": *a4
"429": *a5
/api/medications/layout:
@@ -5563,6 +5568,7 @@ paths:
schema:
type: string
required: true
- *a6
requestBody:
required: true
content:
@@ -5584,6 +5590,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"409": *a7
"422": *a4
"429": *a5
/api/medications/{id}/inventory/{itemId}:
@@ -16633,6 +16640,57 @@ paths:
"401": *a1
"422": *a4
"429": *a5
/api/nightscout/connect:
post:
tags:
- Integrations
summary: Connect (or update) the caller's Nightscout instance
description: Validates the URL and token with a live single-entry probe BEFORE storing anything, so a wrong token, an
unreachable instance or an unapproved private origin surfaces here rather than on the first cron tick. On
success the URL and token are encrypted at rest, the `nightscout` ledger is reset to connected, and a first sync
is enqueued best-effort. Re-sending replaces the stored values; there is no partial update. Body capped at 16
KiB, rate-limited 10 requests / 60 s per user. Auth via cookie or Bearer.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/NightscoutConnectRequest"
responses:
"200":
description: The instance answered the probe and was stored.
content:
application/json:
schema:
$ref: "#/components/schemas/NightscoutConnectEnvelope"
"400":
description: Body is not parseable JSON.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"401": *a1
"413":
description: Body exceeds 16 KiB.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422":
description: "The URL failed validation, the origin is private and the operator has not approved it (`meta.errorCode` =
`private_origin_not_approved` or `invalid_origin`), Nightscout rejected the token, or the instance could not
be reached. Nothing was written. The message is fixed per class and never the upstream error's own: a
Nightscout base URL carries its API token as a query parameter."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"429":
description: More than 10 connection attempts in 60 s from this user.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
/api/nightscout/status:
get:
tags:
@@ -21350,6 +21408,12 @@ components:
type: string
minLength: 1
maxLength: 60
description: "Apple's `HKUnit` string for `value`, captured for audit: the server records it and never validates `value`
against it. `value` is read in Apple's own `HKUnit` for the identifier — `m` for a length, `kg` for body
mass, `kcal` for energy, `degC` for temperature, a `fraction` for the percent-shaped ones (oxygen
saturation, body fat) — and HealthLog applies its storage scaling after that. A waist circumference sent as
`0.86` in `m` is stored as `86` `cm`; an oxygen saturation sent as `0.98` in `fraction` is stored as `98`
`%`. Send the sample in Apple's unit, not in HealthLog's."
startDate:
type: string
format: date-time
@@ -27443,6 +27507,26 @@ components:
- entries
description: Merge-by-`id` update of the HealthKit metric config. Unknown ids are silently ignored; omitted fields fall
back to the stored (or default) value for that entry. Up to 50 entries per call.
NightscoutConnectRequest:
type: object
properties:
url:
type: string
minLength: 1
maxLength: 2048
token:
default: ""
type: string
maxLength: 512
allowPrivateHost:
default: false
type: boolean
required:
- url
description: "Instance URL and optional API token. Both are trimmed before use — a trailing space or newline from a copy
button reaches the instance verbatim and answers as an opaque DNS failure. `token` may be omitted or empty for a
fully public instance (`AUTH_DEFAULT_ROLES=readable`). `allowPrivateHost` is accepted for older clients and
ignored: only the server-side `NIGHTSCOUT_PRIVATE_ORIGINS` allowlist can authorise a private origin."
WhoopCredentialsRequest:
type: object
properties:
@@ -49994,9 +50078,9 @@ components:
additionalProperties:
$ref: "#/components/schemas/MetricDataSummary"
- type: "null"
description: Per-context glucose summaries (FASTING / POSTPRANDIAL / RANDOM / BEDTIME) over the trailing 30 days,
canonical mg/dL. Contexts with no readings are omitted; the whole block is null when the glucose module is
off.
description: Per-context glucose summaries (FASTING / POSTPRANDIAL / RANDOM / BEDTIME, plus UNSPECIFIED for readings
whose source recorded no meal-time context) over the trailing 30 days, canonical mg/dL. Buckets with no
readings are omitted; the whole block is null when the glucose module is off.
glucoseClinical:
anyOf:
- $ref: "#/components/schemas/GlucoseClinicalMetrics"
@@ -62768,6 +62852,30 @@ components:
- truncated
- reasonCode
additionalProperties: false
NightscoutConnectEnvelope:
type: object
properties:
data:
type: object
properties:
connected:
type: boolean
const: true
required:
- connected
additionalProperties: false
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
NightscoutStatusEnvelope:
type: object
properties:
+156
View File
@@ -0,0 +1,156 @@
# Apple Health walking-distance repair
`scripts/repair-apple-health-distance.ts` heals walking distances that the
`export.zip` import stored a thousand times too small.
## The defect
Apple stamps every quantity `<Record>` in an `export.xml` with the account's
own display unit: a metric archive reports `HKQuantityTypeIdentifierDistance-
WalkingRunning` in `km`, an imperial one in `mi`. Until this release the record
mapping accepted that attribute and never read it, so the day's total was
folded as if the number were already metres. A 2.484 km day landed as
`2.484 m`, and an imported decade reads as a couple of metres walked. The
workout path was never affected — it converted its own distance unit.
The code fix converts the record's unit before storage, so every import from
this release on is correct. This script is for the rows already in the
database.
## Prefer a re-import
If the account still has its `export.zip`, **re-import it**. The archive fold
UPSERTs the same `stats:<identifier>:<day>` rows, so a re-import on the fixed
build rewrites every affected day with the right number, needs no flags, and
also repairs rows this script deliberately leaves alone (see the criterion
below). The script exists for accounts that no longer have the archive.
**An account that has been re-imported must not then be repaired.** The
re-import already fixed it. Its rows keep the same `EXPORT_XML_SOURCE_MAX`
stamp and the same `stats:` external id, and they carry no audit row, so the
criterion selects them exactly as it selects a broken account — the stamp
proves where a row came from, not which build imported it. Multiplying them
again turns a 2.484 km day into 2484 km. The script refuses such an account on
its own (any day whose value would leave the plausible range after the
multiply stops the whole account), but that guard fires on the big days: keep
the two apart yourself before you type `--apply`.
## Which rows it touches
Only rows carrying the stamp the archive importer itself writes:
| Column | Value |
| ------------------------ | ------------------------------------------------------------ |
| `type` | `WALKING_RUNNING_DISTANCE` |
| `source` | `APPLE_HEALTH` |
| `aggregation_provenance` | `EXPORT_XML_SOURCE_MAX` |
| `external_id` | `stats:HKQuantityTypeIdentifierDistanceWalkingRunning:<day>` |
| `deleted_at` | `NULL` |
`EXPORT_XML_SOURCE_MAX` is written in exactly one place in the tree — the
archive fold — and the `stats:` external id is minted for that same fold, so
the pair is a proof of origin rather than a guess. Nothing is ever selected
because a value "looks too small".
Deliberately out of scope:
- Rows a native iOS sync has since overwritten (`HEALTHKIT_STATISTICS`): their
value came from HealthKit's own statistics query and is correct.
- Rows whose provenance is `LEGACY_UNKNOWN` or NULL: an archive origin cannot
be proven for them. Re-import the archive to repair those.
## Run a dry-run first
Always, and read it. The dry run is where the decision is made, not a
formality in front of a decision already taken: it is the only place the
script shows you real numbers from the account before it changes them. If the
worked example does not match what that person's Health app shows for that day,
the run is wrong and `--apply` will make it permanent.
The default mode reports and never writes:
```bash
# from a source checkout, with DATABASE_URL pointing at the instance
pnpm dlx tsx scripts/repair-apple-health-distance.ts
```
It prints one line per account: how many rows would be repaired, the date span
they cover, one worked example (`2.484 m -> 2484 m`), and any row whose
repaired value would leave the plausibility range.
This script imports the application dependency graph, so it runs from a source
checkout, not through the production image's `healthlog-tsx` launcher.
## Name the archive's unit
The stored row keeps no record of the unit its archive was written in — that
is the defect — so the operator names it. `--unit=km` is the default and is
what the Health app writes in every metric locale; pass `--unit=mi` for an
imperial archive:
```bash
pnpm dlx tsx scripts/repair-apple-health-distance.ts --unit=mi
```
Check the dry-run's worked example against what the account's Health app shows
for that day before applying. If the two units are mixed across accounts, run
the script once per unit with `--apply` and confirm each account's example
first.
## Apply
Take a backup first — `docs/ops/backup-restore.md`. The multiply is in place
and has no reverse operation: once an account is written wrong, the database
copy you took beforehand is the only way back.
```bash
pnpm dlx tsx scripts/repair-apple-health-distance.ts --apply
```
Per account, in one transaction: every selected row multiplied by the unit's
metre factor, `sync_version` incremented and `updated_at` bumped so paired iOS
clients pick the rows up on their next delta sync, and an audit row
(`measurement.apple_health_distance.repaired`) recording the unit, the factor
and the row count. The DAY/WEEK/MONTH/YEAR rollups and the cached status
insights for the touched days are recomputed afterwards, outside that
transaction.
If that recompute fails, the rows are right and every chart is not, and no
later run of this script would notice — it skips a repaired account. So the
audit row is written with `rollupsRefreshed: false` and only flipped to `true`
once the recompute has returned. A run that could not finish it prints the
account and the command to re-run, exits non-zero, and a later dry run repeats
the warning until the backfill has been run:
```bash
pnpm dlx tsx scripts/backfill-rollups.ts --user <userId>
```
A row whose repaired value would leave the plausible range (0–200 000 m)
refuses the **whole account**: nothing is written, the offending rows and the
reason are printed, and the run moves on to the next account. One such row is
evidence the account is not the 1000x class — a re-imported account looks
exactly like this — and repairing the rest of it would multiply days that were
already right.
## It runs once per account
The audit row is written inside the same transaction as the update, and an
account that carries it is skipped. A second run reports the account as already
repaired and changes nothing, so the multiplier can never be applied twice.
## Verify
```sql
SELECT date_trunc('year', measured_at) AS year,
round(avg(value)) AS avg_metres_per_day
FROM measurements
WHERE user_id = '<userId>'
AND type = 'WALKING_RUNNING_DISTANCE'
AND aggregation_provenance = 'EXPORT_XML_SOURCE_MAX'
GROUP BY 1 ORDER BY 1;
```
A plausible daily average is thousands of metres. Single digits mean the rows
were not repaired; millions mean a factor was applied twice, which the audit
row is there to prevent.
+6 -3
View File
@@ -41,7 +41,7 @@ the restore script needs no flag to tell them apart.
| `BACKUP_S3_ACCESS_KEY` | yes | |
| `BACKUP_S3_SECRET_KEY` | yes | |
| `BACKUP_S3_REGION` | no | defaults to `auto` (Cloudflare R2) |
| `BACKUP_RETENTION_DAYS` | no | defaults to `30` |
| `BACKUP_RETENTION_DAYS` | no | not read by the app; the number for your bucket's lifecycle rule |
## Bucket permissions
@@ -54,8 +54,11 @@ Cloudflare R2 the **Object Read & Write** token already covers all three.
## Bucket lifecycle (recommended)
The worker prunes objects older than `BACKUP_RETENTION_DAYS`, but the
storage provider's lifecycle rule is the canonical safety net:
The worker never deletes a backup object: its grant covers PutObject,
GetObject and AbortMultipartUpload only, so a compromised worker cannot
wipe the history, and nothing in the app reads `BACKUP_RETENTION_DAYS`.
Expiry is the storage provider's lifecycle rule, set to match the number
you wrote there:
```
Filter: "" (all objects)
+49
View File
@@ -94,6 +94,55 @@ Pass `--only=prod` / `--only=demo` to check a single target, or
`--attempts=N --interval=ms` to widen the retry window while a slow
pull settles.
## Walk the synthetic journey
The version check proves the instance serves the right build. It does not
prove a person can use it. `scripts/synthetic-journey.mjs` walks the
shortest journey that touches the whole stack — sign in, write a reading,
read it back, delete it again — against a running instance:
```sh
# Plain Node 22, no dependencies: runs from a workstation, a runner, or
# the deploy host. Warms the instance first, then asserts each leg.
BASE_URL=https://review.healthlog.dev \
SYNTHETIC_USERNAME=<probe account> \
SYNTHETIC_PASSWORD=<its password> \
EXPECTED_VERSION=v1.38.13 \
node scripts/synthetic-journey.mjs
```
It prints one line per leg with the elapsed time and stops at the first
failure with the status and a redacted body excerpt. The warm-up hit on
each route is timed but discarded — the first request after a deploy pays
the cold-start cost and says nothing about the instance's steady state.
The same journey is available as the **Synthetic journey** workflow
(`workflow_dispatch`, inputs `base_url` and `expected_version`), which
reads the credentials from the `SYNTHETIC_USERNAME` / `SYNTHETIC_PASSWORD`
repository secrets. Nothing calls it from the deploy path; run it by hand
after a deploy until a target instance is settled.
Two preconditions on the probe account: it must be password-only, because
the password flow stops at the MFA ticket for an account with a second
factor, and the target must not be the demo instance, where every mutation
is blocked and the write leg would be red for a reason unrelated to the
deploy.
**A red result.** The leg that failed names the layer:
| Red leg | What it means |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `version` | The instance serves a different build than the tag you deployed. Check the image digest and the `pull_policy` before looking anywhere else. |
| `sign in` | Sessions are broken, or the probe account gained a second factor. A 429 here is the login rate limit, not the deploy. |
| `write` | The write path is down: database, encryption keys, or a migration that did not run. |
| `read back` | A value was written and cannot be read back — the shape this project keeps rediscovering. Roll back. |
| `delete` | The write path works and the reversible delete does not; the probe reading is still on the account and needs removing by hand. |
Run `node scripts/synthetic-journey.mjs --self-test` to confirm the check
itself still works: it walks the journey against an in-process instance
once per leg with that leg broken, and fails if any of them comes back
green.
## Verify the image signature
Every release image (the multi-arch manifest **index**, covering both
+1
View File
@@ -14,6 +14,7 @@ The Playwright config (`../playwright.config.ts`) builds the app (`next build`)
- **`setup/`** — `test.ts` (the suite's `test` + `expect` entry point), `global-setup.ts` (auth + DB seeding), `test-helpers.ts` (shared login + navigation helpers).
- **`utils/`** — fixtures such as `mock-dashboard-snapshot.ts`.
- **`fixtures/`** — synthetic payloads a journey uploads, built in code rather than committed as blobs so the content stays reviewable (`apple-health-export.ts`).
- **`*.spec.ts`** — one spec per flow: auth/login/redirect, dashboard, charts, the medication-wizard cadences (`medications-wizard-*`), insights generation + scroll restoration, doctor report, settings, mobile-viewport consistency, locale switch, accessibility (`a11y.spec.ts`), and the `v1427-*` regression set.
## Conventions
+29 -11
View File
@@ -75,6 +75,20 @@ const fixedMeasurementRows = Array.from({ length: 10 }, (_, index) => ({
notes: null,
}));
// Ten daily mood scores ending yesterday, anchored for the same reason the
// measurement rows above are: the mood chart windows by calendar day and
// withholds its line below three DISTINCT days, so a fixed date left the mood
// sub-page painting its sparse placeholder. The scan's painted gate was then
// satisfied by a different chart on the page, and the mood line — the reason
// the case exists — was never waited for and might never have been scanned.
const fixedMoodEntries = Array.from({ length: 10 }, (_, index) => ({
date: new Date(Date.now() - (index + 1) * 86_400_000)
.toISOString()
.slice(0, 10),
score: 3 + (index % 3) * 0.5,
samples: 1,
}));
const workoutListEntry = {
id: A11Y_WORKOUT_ID,
sportType: "running",
@@ -330,8 +344,8 @@ async function installA11yMocks(page: Page) {
await page.route("**/api/mood/analytics", (route) =>
fulfilJson(route, {
entries: [{ date: "2026-07-20", score: 4 }],
summary: { count: 1 },
entries: fixedMoodEntries,
summary: { count: fixedMoodEntries.length },
}),
);
@@ -342,11 +356,8 @@ async function installA11yMocks(page: Page) {
// present-but-empty; the correlations block carries every metric key.
await page.route("**/api/mood/insights", (route) =>
fulfilJson(route, {
summary: { totalEntries: 1, inTargetPct: null },
heatmap: {
windowDays: 30,
cells: [{ date: "2026-07-20", score: 4, samples: 1 }],
},
summary: { totalEntries: fixedMoodEntries.length, inTargetPct: null },
heatmap: { windowDays: 30, cells: fixedMoodEntries },
distribution: [],
weekday: [],
timeOfDay: { buckets: [], reliable: false, best: null, worst: null },
@@ -712,18 +723,25 @@ const INSIGHTS_ROUTES: readonly RouteCase[] = [
}),
},
{
// `.recharts-wrapper` is a class the charting library writes, not a
// contract this repository keeps, and it is shared by every chart on the
// page — so the gate could be satisfied by a chart other than the one the
// route exists to render, and it moves whenever the library's markup does.
// `chart-plot` is the measurement chart's own slot and the data branch is
// the only branch that renders it: an empty window paints `ChartEmptyState`
// instead and this scan still waits, which is what it is for.
name: "/insights/weight metric subpage",
path: "/insights/weight",
painted: (page) => page.locator(".recharts-wrapper").first(),
painted: (page) => page.locator('[data-slot="chart-plot"]').first(),
},
{
// Mood is event-driven, so its sub-page renders the heatmap and the mood
// line chart from `/api/mood/insights` rather than a MeasurementType series.
// Gate on the line chart's recharts wrapper — the same painted signal the
// weight sub-page uses — so the scan waits for the heavy content to land.
// The mood chart carries the same `chart-plot` slot on the same terms, so
// the scan waits for the heavy content to land here too.
name: "/insights/mood metric subpage",
path: "/insights/mood",
painted: (page) => page.locator(".recharts-wrapper").first(),
painted: (page) => page.locator('[data-slot="chart-plot"]').first(),
},
{
// The two states this page's OWN render produces: the list, or the
+197
View File
@@ -0,0 +1,197 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
appleHealthExportZip,
archiveWithoutExportXml,
FIXTURE_READINGS,
MISSING_EXPORT_XML_REASON,
} from "./fixtures/apple-health-export";
/**
* Apple Health `export.zip` → readings on the measurements list.
*
* The one journey that crosses every layer the import owns: a person drops
* an archive on `/settings/export`, the kick-off route streams it to disk and
* queues `apple-health-import-v2`, the pg-boss worker in the same process
* unpacks it and streams the XML into `Measurement` rows, the card's status
* poll turns the spinner into a written outcome, and the rows show up on
* `/measurements` carrying the units the parser resolved. Every one of those
* steps has unit coverage; none of it proves the chain holds when a real
* browser drives it against a real queue and a real database.
*
* The archives are synthetic and built in memory — see
* `fixtures/apple-health-export.ts`.
*/
type FixtureReading = (typeof FIXTURE_READINGS)[keyof typeof FIXTURE_READINGS];
/**
* Serial, because the journey mutates the one shared account and the refusal
* control reads a count off it. Two of these tests in flight at once would
* also spend the kick-off route's three-uploads-per-minute budget on each
* other rather than on what they are testing.
*/
test.describe.configure({ mode: "serial" });
/**
* The import is the slowest single action in the browser suite: an upload,
* a queue hop, an unpack, a parse, and a status poll that ticks every two
* seconds. The default 30 s budget covers none of that on a loaded runner.
*/
test.setTimeout(180_000);
/**
* Clear this account's upload bucket before each upload.
*
* `POST /api/import/apple-health-export` allows three uploads per user per
* minute, which is the right production number and exactly one too few for a
* spec that uploads twice and is expected to survive `--repeat-each`. The
* fourth upload inside the window would come back 429 and the card would
* render the rate-limit copy — a red run that says nothing about the import.
*
* Reaching for Postgres directly is the same channel `global-setup.ts` uses
* to seed and to clear the auth buckets; the alternative is to sit out a
* 60-second window, which is a sleep wearing a poll's clothing.
*/
async function clearImportUploadBucket(): Promise<void> {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error(
"DATABASE_URL is unset — the import journey needs the same database the app is running against.",
);
}
const { default: pg } = await import("pg");
const client = new pg.Client({ connectionString });
await client.connect();
try {
await client.query(
`DELETE FROM rate_limits WHERE key LIKE 'import:apple-health:%'`,
);
} finally {
await client.end();
}
}
/** Open the import surface and hand back the Apple Health card. */
async function openImportCard(page: Page) {
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
const card = page.getByTestId("import-card-apple-health");
await expect(card).toBeVisible({ timeout: 30_000 });
return card;
}
/**
* Hand the archive to the card's own file input, which is what a drop or a
* click on "Choose file" ends up doing. The upload itself then runs through
* the card's `fetch`, so the spec seeds nothing the page would not have sent.
*/
async function uploadArchive(
page: Page,
archive: Buffer,
name: string,
): Promise<void> {
const card = await openImportCard(page);
await card.locator('input[type="file"]').setInputFiles({
name,
mimeType: "application/zip",
buffer: archive,
});
}
/** How many readings this account holds from an Apple Health import. */
async function appleHealthRowCount(page: Page): Promise<number> {
return page.evaluate(async () => {
const res = await fetch(
"/api/measurements?sourceEq=APPLE_HEALTH&limit=1&offset=0",
{ credentials: "include" },
);
const body = (await res.json()) as {
data: { meta: { total: number } } | null;
};
if (!body.data) throw new Error("measurements list returned no data");
return body.data.meta.total;
});
}
/**
* Assert one imported reading on the measurements list, by the value and the
* unit the row carries rather than by the locale text it renders.
*/
async function expectReadingOnList(
page: Page,
reading: FixtureReading,
): Promise<void> {
await page.goto(`/measurements?type=${reading.type}`, {
waitUntil: "domcontentloaded",
});
await expect(page.locator('[data-slot="measurement-rows"]')).toBeVisible({
timeout: 30_000,
});
const row = page.locator(
`[data-testid="measurement-row"][data-measurement-type="${reading.type}"]` +
`[data-measurement-value="${reading.value}"]`,
);
await expect(row).toHaveCount(1);
await expect(row).toHaveAttribute("data-measurement-unit", reading.unit);
}
test.describe("Apple Health export import", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("an uploaded export surfaces as readings with their units", async ({
page,
}) => {
await clearImportUploadBucket();
await uploadArchive(page, appleHealthExportZip(), "export.zip");
// The card polls the status route every two seconds and swaps the
// spinner for the written outcome on a terminal state. `data-outcome`
// is the outcome itself; the sentence beside it is translated copy.
const result = page.getByTestId("import-apple-health-result");
await expect(result).toBeVisible({ timeout: 150_000 });
await expect(result).toHaveAttribute("data-outcome", "success");
// The weight the archive wrote in kilograms, the blood-pressure pair it
// wrote in mmHg, and the step count it wrote as a bare count — each on
// the list, each carrying the unit the parser resolved for it.
await expectReadingOnList(page, FIXTURE_READINGS.weight);
await expectReadingOnList(page, FIXTURE_READINGS.systolic);
await expectReadingOnList(page, FIXTURE_READINGS.diastolic);
await expectReadingOnList(page, FIXTURE_READINGS.steps);
});
test("a distance written in kilometres is stored in metres", async ({
page,
}) => {
test.skip(
true,
"issue #944 — the parser resolves a record's unit from the type map " +
"and ignores the `unit` attribute the record carries, so a distance " +
"written as 2.484 km lands as 2.484 m. The fix is on a branch that " +
"has not reached main; this is the contract it has to meet, kept " +
"here at full strength rather than relaxed to match today's value.",
);
await expectReadingOnList(page, FIXTURE_READINGS.walkingDistance);
});
test("an archive without export.xml is refused and imports nothing", async ({
page,
}) => {
await clearImportUploadBucket();
await openImportCard(page);
const before = await appleHealthRowCount(page);
await uploadArchive(page, archiveWithoutExportXml(), "not-an-export.zip");
// The worker's own words, surfaced verbatim by the card: the failure
// reason is honest English free text, not a code the UI translates.
const error = page.getByTestId("import-apple-health-error");
await expect(error).toBeVisible({ timeout: 150_000 });
await expect(error).toContainText(MISSING_EXPORT_XML_REASON);
// And a refusal that still wrote something would be worse than no
// refusal at all.
expect(await appleHealthRowCount(page)).toBe(before);
});
});
+22 -4
View File
@@ -345,6 +345,16 @@ test.describe("document vault", () => {
await detail.locator('[data-slot="document-chat-open"]').click();
const drawer = page.locator('[data-slot="coach-drawer"]');
await expect(drawer).toBeVisible();
// The drawer being on screen is not the same event as the vault having
// recorded the hand-off. The page books `?doc=` as the way back only once
// the detail sheet is actually closed with the drawer owning the same
// document; maximize before that and the close path takes the other branch
// — `history.back()` — which races the push to `/coach` and lands back on
// `/documents` with nothing raised. The sheet's own close control leaves
// the tree with the sheet, so its absence is that hand-off, observed.
await expect(
page.locator('[data-slot="document-detail-close"]'),
).toHaveCount(0);
await drawer.locator('[data-slot="coach-drawer-maximize"]').click();
await expect.poll(() => new URL(page.url()).pathname).toBe("/coach");
expect(new URL(page.url()).searchParams.get("doc")).toBe(MRT_DOC_ID);
@@ -392,11 +402,19 @@ test.describe("document vault", () => {
await sheet.getByRole("button", { name: "Delete" }).click();
// Undo toast → restore → the card returns.
await expect(
page.getByText("Document deleted", { exact: true }),
).toBeVisible();
//
// Addressed to the toast's own attributes, not to its copy. The copy is
// i18n-driven and the literal here has already been wrong once — a trailing
// full stop moved and the assertion spent its whole timeout looking for a
// sentence that no longer existed, on every attempt, on both projects. The
// success type is what the delete claims and the action button is what the
// undo needs; both are structural.
const undo = page.locator(
'[data-sonner-toast][data-type="success"] [data-button]',
);
await expect(undo).toBeVisible();
await expect(openButton(page, `${title}.pdf`)).not.toBeVisible();
await page.getByRole("button", { name: "Undo" }).click();
await undo.click();
await expect(openButton(page, `${title}.pdf`)).toBeVisible();
});
+188
View File
@@ -0,0 +1,188 @@
/**
* Synthetic Apple Health `export.zip` fixtures for the import journey.
*
* The archives are built in memory rather than committed as binaries, for
* two reasons. The XML stays readable in the diff — a fixture whose content
* only exists inside a blob is a fixture nobody reviews — and the bytes stay
* deterministic, which the kick-off route's content-hash idempotency needs:
* a re-run of the same spec (`--repeat-each`) must resolve to the job the
* first run created instead of queueing a second import of the same export.
*
* The data is invented. No real reading, account, device or name appears
* here; every source is a fixture label and every timestamp is a fixed day
* in the past chosen so it cannot collide with anything the rest of the
* suite writes.
*/
import { crc32, deflateRawSync } from "node:zlib";
/** The calendar day (Europe/Berlin) every fixture record is stamped on. */
export const FIXTURE_DAY = "2019-03-04";
/**
* What the archive claims, and what the measurements list must show for it.
*
* `value` is the number the row carries once the import has run, in the
* canonical DB unit — which is NOT always the unit the record was written
* in. That gap is the point of the walking-distance entry below.
*/
export const FIXTURE_READINGS = {
weight: { type: "WEIGHT", value: 74.2, unit: "kg" },
systolic: { type: "BLOOD_PRESSURE_SYS", value: 118, unit: "mmHg" },
diastolic: { type: "BLOOD_PRESSURE_DIA", value: 76, unit: "mmHg" },
steps: { type: "ACTIVITY_STEPS", value: 8421, unit: "steps" },
/**
* Written as `unit="km"`, stored in metres: 2.484 km is 2484 m. Issue #944
* — the parser reads the mapping's unit and ignores the record's own, so
* today the row lands as 2.484 m.
*/
walkingDistance: { type: "WALKING_RUNNING_DISTANCE", value: 2484, unit: "m" },
} as const;
/** The refusal the extractor raises for an archive with no `export.xml`. */
export const MISSING_EXPORT_XML_REASON =
"Archive is missing the `apple_health_export/export.xml` member";
const EXPORT_XML = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE HealthData [<!ELEMENT HealthData (Record|Workout|ClinicalRecord)*>]>
<HealthData locale="en_US">
<ExportDate value="${FIXTURE_DAY} 21:00:00 +0100"/>
<Me HKCharacteristicTypeIdentifierDateOfBirth="1980-01-01"/>
<Record type="HKQuantityTypeIdentifierBodyMass"
unit="kg"
startDate="${FIXTURE_DAY} 07:15:00 +0100"
endDate="${FIXTURE_DAY} 07:15:00 +0100"
value="${FIXTURE_READINGS.weight.value}"
sourceName="Fixture Scale"
sourceVersion="1.0"/>
<Record type="HKQuantityTypeIdentifierBloodPressureSystolic"
unit="mmHg"
startDate="${FIXTURE_DAY} 07:20:00 +0100"
endDate="${FIXTURE_DAY} 07:20:00 +0100"
value="${FIXTURE_READINGS.systolic.value}"
sourceName="Fixture Cuff"
sourceVersion="1.0"/>
<Record type="HKQuantityTypeIdentifierBloodPressureDiastolic"
unit="mmHg"
startDate="${FIXTURE_DAY} 07:20:00 +0100"
endDate="${FIXTURE_DAY} 07:20:00 +0100"
value="${FIXTURE_READINGS.diastolic.value}"
sourceName="Fixture Cuff"
sourceVersion="1.0"/>
<Record type="HKQuantityTypeIdentifierStepCount"
unit="count"
startDate="${FIXTURE_DAY} 09:00:00 +0100"
endDate="${FIXTURE_DAY} 09:30:00 +0100"
value="${FIXTURE_READINGS.steps.value}"
sourceName="Fixture Phone"
sourceVersion="1.0"/>
<Record type="HKQuantityTypeIdentifierDistanceWalkingRunning"
unit="km"
startDate="${FIXTURE_DAY} 09:00:00 +0100"
endDate="${FIXTURE_DAY} 09:30:00 +0100"
value="2.484"
sourceName="Fixture Phone"
sourceVersion="1.0"/>
</HealthData>
`;
interface ZipMember {
name: string;
payload: Buffer;
}
/**
* Minimal ZIP writer — deflate-compressed members, no Zip64, fixed
* (zeroed) modification stamps so the same members always produce the same
* bytes. Mirrors the hand-built archives in
* `src/lib/import/__tests__/unzip-export-xml.test.ts`; the extractor under
* test reads the central directory, which is what this emits.
*/
function buildZip(members: readonly ZipMember[]): Buffer {
const locals: Buffer[] = [];
const central: Buffer[] = [];
let offset = 0;
for (const member of members) {
const nameBuf = Buffer.from(member.name, "utf8");
const compressed = deflateRawSync(member.payload);
const crc = crc32(member.payload);
const localHeader = Buffer.alloc(30);
localHeader.writeUInt32LE(0x04034b50, 0);
localHeader.writeUInt16LE(20, 4); // version needed
localHeader.writeUInt16LE(0, 6); // flags
localHeader.writeUInt16LE(8, 8); // method: deflate
localHeader.writeUInt16LE(0, 10); // mtime
localHeader.writeUInt16LE(0, 12); // mdate
localHeader.writeUInt32LE(crc, 14);
localHeader.writeUInt32LE(compressed.length, 18);
localHeader.writeUInt32LE(member.payload.length, 22);
localHeader.writeUInt16LE(nameBuf.length, 26);
localHeader.writeUInt16LE(0, 28); // extra length
const centralHeader = Buffer.alloc(46);
centralHeader.writeUInt32LE(0x02014b50, 0);
centralHeader.writeUInt16LE(20, 4); // version made by
centralHeader.writeUInt16LE(20, 6); // version needed
centralHeader.writeUInt16LE(0, 8); // flags
centralHeader.writeUInt16LE(8, 10); // method: deflate
centralHeader.writeUInt16LE(0, 12); // mtime
centralHeader.writeUInt16LE(0, 14); // mdate
centralHeader.writeUInt32LE(crc, 16);
centralHeader.writeUInt32LE(compressed.length, 20);
centralHeader.writeUInt32LE(member.payload.length, 24);
centralHeader.writeUInt16LE(nameBuf.length, 28);
centralHeader.writeUInt16LE(0, 30); // extra
centralHeader.writeUInt16LE(0, 32); // comment
centralHeader.writeUInt16LE(0, 34); // disk number
centralHeader.writeUInt16LE(0, 36); // internal attrs
centralHeader.writeUInt32LE(0, 38); // external attrs
centralHeader.writeUInt32LE(offset, 42);
locals.push(localHeader, nameBuf, compressed);
central.push(centralHeader, nameBuf);
offset += localHeader.length + nameBuf.length + compressed.length;
}
const centralBuf = Buffer.concat(central);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(0, 4); // disk
eocd.writeUInt16LE(0, 6); // disk with central directory
eocd.writeUInt16LE(members.length, 8);
eocd.writeUInt16LE(members.length, 10);
eocd.writeUInt32LE(centralBuf.length, 12);
eocd.writeUInt32LE(offset, 16);
eocd.writeUInt16LE(0, 20); // comment length
return Buffer.concat([...locals, centralBuf, eocd]);
}
/** A well-formed export archive carrying the five fixture records. */
export function appleHealthExportZip(): Buffer {
return buildZip([
{
name: "apple_health_export/export.xml",
payload: Buffer.from(EXPORT_XML, "utf8"),
},
]);
}
/**
* An archive that is a valid ZIP and carries no `export.xml` — the shape a
* person produces by zipping the wrong folder. The import must refuse it and
* write nothing.
*/
export function archiveWithoutExportXml(): Buffer {
return buildZip([
{
name: "apple_health_export/export_cda.xml",
payload: Buffer.from("<ClinicalDocument/>\n", "utf8"),
},
]);
}
+418
View File
@@ -0,0 +1,418 @@
/**
* Blood glucose, from the first reading to the tile — one journey.
*
* The flow this file is checked against, in one script:
*
* 1. a reading arrives with no meal-time context, the way a meter export or
* a device sync delivers one;
* 2. a person captures a reading by hand in mg/dL;
* 3. the list shows both in mg/dL;
* 4. they switch the account's display unit to mmol/L in settings;
* 5. the capture form now ASKS in mmol/L, and a reading typed there is
* stored canonically — the list shows all three in mmol/L, each one
* converted, none of them reinterpreted;
* 6. the dashboard paints the blood-glucose tile in the same unit.
*
* Refusing control: a value outside the accepted mg/dL band is refused by the
* server, the capture stays open with an error, and the list is exactly what
* it was. A journey that only ever writes acceptable numbers proves the happy
* path and nothing about the boundary.
*
* Every assertion addresses a stable attribute — `data-testid`,
* `data-measurement-type`, `data-display-unit`, `data-slot`, a placeholder —
* never rendered copy: the labels are i18n-driven, and the same rows paint
* through a different subtree on the card list.
*
* The flows mutate the one seeded account (its glucose rows AND its display
* unit), so — like `visits.spec.ts` and `vaccinations.spec.ts` — this file
* runs in a single project (see `playwright.config.ts`), runs serial, and
* resets both before each test.
*/
import type { Page } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { resetGlucose } from "./setup/glucose-fixture";
import { expect, test } from "./setup/test";
/** Every blood-glucose row the list is currently painting. */
const GLUCOSE_ROWS =
'[data-testid="measurement-row"][data-measurement-type="BLOOD_GLUCOSE"]';
/** The list has finished its read — rows, or the honest empty state. */
const LIST_SETTLED =
'[data-slot="measurement-rows"], [data-slot="empty-state"]';
/**
* Canonical mg/dL in, display mmol/L out (18.0182 mg/dL per mmol/L, rounded
* to one fraction digit). Written out rather than imported so the spec pins
* the numbers a reader sees instead of re-running the app's own arithmetic
* and agreeing with itself.
*/
const READINGS = {
/** Delivered with no meal-time context. */
untagged: { mgdl: 112, mmol: "6.2" },
/** Typed by hand while the account reads mg/dL. */
typedAsMgdl: { mgdl: 95, mmol: "5.3" },
/** Typed by hand once the account reads mmol/L; stored as 130 mg/dL. */
typedAsMmol: { typed: "7.2", mmol: "7.2" },
} as const;
/** A locale-tolerant matcher for a decimal the formatter may render "5,3". */
function decimal(value: string): RegExp {
return new RegExp(value.replace(".", "[.,]"));
}
async function openList(page: Page): Promise<void> {
await page.goto("/measurements");
await expect(page.locator(LIST_SETTLED).first()).toBeVisible({
timeout: 30_000,
});
}
/**
* Deliver a reading the way a meter export does: through the batch ingest,
* which is the one write surface that accepts a glucose row with no
* meal-time context. The hand-entry schema requires one — a person at the
* reading knows the answer — so this row cannot be produced by the form, and
* producing it any other way would be testing a fixture rather than a
* contract.
*
* The POST rides the PAGE's own `fetch`, not a shared Playwright request
* context: a pooled context can hand a POST to a keep-alive socket the server
* has already closed, and the retry lands as a second row.
*/
async function deliverUntaggedReading(page: Page, mgdl: number): Promise<void> {
const result = await page.evaluate(async (value: number) => {
const at = new Date().toISOString();
const res = await fetch("/api/measurements/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
entries: [
{
hkIdentifier: "HKQuantityTypeIdentifierBloodGlucose",
value,
unit: "mg/dL",
startDate: at,
endDate: at,
externalId: `e2e-glucose-untagged-${at}`,
source: "MANUAL",
},
],
}),
});
return {
status: res.status,
body: (await res.json()) as { data?: { inserted?: number } },
};
}, mgdl);
expect(result.status, "the batch ingest accepted the untagged row").toBe(200);
expect(result.body.data?.inserted).toBe(1);
}
/**
* Fold the account's measurement rollups, synchronously, through the app's own
* operator endpoint.
*
* The dashboard's glucose tiles ride the snapshot's THICK phase, and that
* phase refuses to run until the rollup tier is warm for the types the account
* holds — a cold tier would drop the whole strip into a live-SQL fallback and
* make every tile wait on the slowest read. Rows seeded straight into Postgres
* by `global-setup` are never folded (nothing hooks a raw INSERT), so on a
* freshly created database the phase is cold, `extras` comes back null, and
* the tile a person would see on their own instance is simply absent here.
*
* That is a property of the harness, not of the feature, so the journey warms
* the tier rather than asserting around it: one call, awaited, before the
* dashboard read.
*/
async function warmRollupTier(page: Page): Promise<void> {
const result = await page.evaluate(async () => {
const me = (await (await fetch("/api/auth/me")).json()) as {
data: { id: string };
};
const res = await fetch("/api/admin/rollups/recompute", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: me.data.id }),
});
return {
status: res.status,
body: (await res.json()) as { data?: { rowsUpserted?: number } },
};
});
expect(result.status, "the rollup fold ran").toBe(200);
expect(result.body.data?.rowsUpserted ?? 0).toBeGreaterThan(0);
}
/** The ISO day before the one the capture form seeded itself with. */
function previousDay(isoDay: string): string {
const day = new Date(`${isoDay}T00:00:00.000Z`);
day.setUTCDate(day.getUTCDate() - 1);
return day.toISOString().slice(0, 10);
}
/**
* Drive the capture sheet through the `?add=` deep link the Insights actions
* already use, fill the single value field, and save. Returns the status the
* server answered the write with, so a caller can assert the refusal without
* reading it back off the copy in the banner.
*/
async function captureReading(
page: Page,
opts: {
typed: string;
context?: string;
expectPlaceholder?: string;
/** Backdate to `HH:mm` on the day before the form's own default. */
yesterdayAt?: string;
},
): Promise<number> {
await page.goto("/measurements?add=BLOOD_GLUCOSE");
const sheet = page.locator('[data-slot="responsive-sheet-content"]');
await expect(sheet).toBeVisible({ timeout: 30_000 });
const valueField = sheet.locator("#value");
await expect(valueField).toBeVisible();
if (opts.expectPlaceholder) {
// The placeholder is the form saying which unit it is asking in, in an
// attribute rather than in copy.
await expect(valueField).toHaveAttribute(
"placeholder",
opts.expectPlaceholder,
);
}
await valueField.fill(opts.typed);
if (opts.yesterdayAt) {
// Two readings that share a minute share a row identity — `(user, type,
// measuredAt)` is unique and the second one is refused — so a journey
// that captures twice has to place them, exactly as a person would.
// Yesterday rather than an earlier hour today: any hour is in the past on
// a past day, so the picker's no-future clamp cannot pull the instant
// back onto the same minute as the other reading whatever the hour the
// suite happens to run at.
const dateField = page.locator(
'[data-slot="date-time-field"] [data-slot="date-field"]',
);
const seededDay = await dateField
.locator('input[type="hidden"]')
.inputValue();
const day = previousDay(seededDay);
// The overlay parses a clean ISO string ahead of the account's own
// date-format preference, so the spec does not have to know it.
await dateField.locator('input[type="text"]').fill(day);
await dateField.locator('input[type="text"]').blur();
await expect(dateField.locator('input[type="hidden"]')).toHaveValue(day);
const timeField = page.locator(
'[data-slot="date-time-field"] [data-slot="time-field"]',
);
await timeField.locator('input[type="text"]').fill(opts.yesterdayAt);
await timeField.locator('input[type="text"]').blur();
await expect(timeField.locator('input[type="hidden"]')).toHaveValue(
opts.yesterdayAt,
);
}
if (opts.context) {
// The meal-time context defaults to fasting; anything else is a choice
// the person makes, addressed by the enum the option carries.
await sheet.locator("#glucose-context").click();
const option = page.locator(
`[data-slot="select-item"][data-glucose-context="${opts.context}"]`,
);
await expect(option).toBeVisible();
await option.click();
await expect(option).toHaveCount(0);
}
const write = page.waitForResponse(
(res) =>
new URL(res.url()).pathname === "/api/measurements" &&
res.request().method() === "POST",
);
await sheet
.locator('[data-slot="responsive-sheet-footer"] button[type="submit"]')
.click();
return (await write).status();
}
/** Switch the account's blood-glucose display unit through settings. */
async function chooseGlucoseUnit(page: Page, unit: string): Promise<void> {
await page.goto("/settings/account");
const select = page.getByTestId("settings-glucose-unit-select");
await expect(select).toBeVisible({ timeout: 30_000 });
// The control disables itself until the account payload has landed; a
// change dispatched before that is dropped by its own guard.
await expect(select).toBeEnabled();
const saved = page.waitForResponse(
(res) =>
new URL(res.url()).pathname === "/api/auth/me/glucose-unit" &&
res.request().method() === "PATCH",
);
await select.selectOption(unit);
expect((await saved).status()).toBe(200);
await expect(select).toHaveValue(unit);
}
test.beforeEach(async () => {
// Own every row the counts below read, and undo the switch the journey
// itself performs — the unit is a column on the shared account, so a run
// that left it on mmol/L would start the next one past the half it means
// to prove.
await resetGlucose();
});
// Serial and single-account: the reset above would race a sibling worker, and
// two tests clearing the same rows would each undo the other's setup.
test.describe.configure({ mode: "serial" });
test.describe("blood glucose", () => {
test.use({ storageState: STORAGE_STATE_PATH });
// The dashboard is the most expensive page in the product to render cold,
// and this journey ends there after four navigations. The budget is the
// page's; the assertions stay exactly as tight as they were.
test.setTimeout(120_000);
test("readings captured either side of a unit switch all read in the chosen unit", async ({
page,
}) => {
await openList(page);
await deliverUntaggedReading(page, READINGS.untagged.mgdl);
await warmRollupTier(page);
// Hand entry, while the account still reads mg/dL.
expect(
await captureReading(page, {
typed: String(READINGS.typedAsMgdl.mgdl),
yesterdayAt: "07:30",
expectPlaceholder: "95",
}),
).toBe(201);
await openList(page);
const rows = page.locator(GLUCOSE_ROWS);
await expect(rows).toHaveCount(2);
await expect(rows.first()).toHaveAttribute("data-display-unit", "mg/dL");
await expect(
page.locator(`${GLUCOSE_ROWS}[data-display-unit="mg/dL"]`),
).toHaveCount(2);
// The switch.
await chooseGlucoseUnit(page, "mmol/L");
// The form now ASKS in mmol/L — its placeholder says so — and what it
// stores is still canonical mg/dL, which is what the read-back proves.
// Bedtime rather than fasting, so each hand-entered reading owns a tile
// and each tile's "latest" names exactly one of them — a strip where two
// readings share a bucket would prove only that one of them won.
expect(
await captureReading(page, {
typed: READINGS.typedAsMmol.typed,
context: "BEDTIME",
expectPlaceholder: "5.3",
}),
).toBe(201);
await openList(page);
await expect(rows).toHaveCount(3);
await expect(
page.locator(`${GLUCOSE_ROWS}[data-display-unit="mmol/L"]`),
).toHaveCount(3);
// Each reading converted, none reinterpreted: 112 → 6.2, 95 → 5.3, and
// the 7.2 typed in mmol/L comes back as the 7.2 it was.
for (const expected of [
READINGS.untagged.mmol,
READINGS.typedAsMgdl.mmol,
READINGS.typedAsMmol.mmol,
]) {
await expect(
rows
.locator('[data-slot="measurement-row-value"]')
.filter({ hasText: decimal(expected) }),
).toHaveCount(1);
}
// The dashboard reads the same account preference: one tile per meal-time
// context, each carrying its own reading in mmol/L.
await page.goto("/");
for (const [tileId, value] of [
["glucose-FASTING", READINGS.typedAsMgdl.mmol],
["glucose-BEDTIME", READINGS.typedAsMmol.mmol],
] as const) {
const tile = page.locator(
`[data-slot="dashboard-tile-link"][data-tile-id="${tileId}"]`,
);
await expect(tile).toBeVisible({ timeout: 60_000 });
await expect(tile.locator('[data-slot="trend-card-value"]')).toHaveText(
decimal(value),
);
}
});
test("a reading with no meal-time context gets a tile of its own", async ({
page,
}) => {
test.skip(
true,
"#943 — tile eligibility filters the four named meal-time contexts " +
"against the per-context summaries, so an account whose source records " +
"no meal time matches none of them and the strip is silently short one " +
"card. The fix names the untagged bucket and walks it with the rest; it " +
"is not on main yet. Unskip with it, do not weaken the assertion — a " +
"check that cannot fail is worse than none.",
);
await openList(page);
await deliverUntaggedReading(page, READINGS.untagged.mgdl);
await warmRollupTier(page);
await page.goto("/");
const tile = page.locator(
'[data-slot="dashboard-tile-link"][data-tile-id="glucose-UNSPECIFIED"]',
);
await expect(tile).toBeVisible({ timeout: 60_000 });
await expect(tile.locator('[data-slot="trend-card-value"]')).toHaveText(
decimal(String(READINGS.untagged.mgdl)),
);
});
test("a value outside the accepted band is refused and the list is unchanged", async ({
page,
}) => {
await openList(page);
await deliverUntaggedReading(page, READINGS.untagged.mgdl);
await openList(page);
await expect(page.locator(GLUCOSE_ROWS)).toHaveCount(1);
// 900 mg/dL is past the plausible ceiling (20–800). The field carries no
// min/max of its own, so the refusal is the server's and this is a real
// round trip, not a browser-side block.
expect(await captureReading(page, { typed: "900" })).toBe(422);
// The capture stays open, carrying the error and the entered value: a
// refusal must not cost the person what they typed.
const sheet = page.locator('[data-slot="responsive-sheet-content"]');
await expect(sheet.getByRole("alert")).toBeVisible();
await expect(sheet).toBeVisible();
await expect(sheet.locator("#value")).toHaveValue("900");
// And the list behind it is exactly what it was.
await openList(page);
const rows = page.locator(GLUCOSE_ROWS);
await expect(rows).toHaveCount(1);
await expect(rows.first()).toHaveAttribute("data-display-unit", "mg/dL");
await expect(
rows.locator('[data-slot="measurement-row-value"]'),
).toHaveText(decimal(String(READINGS.untagged.mgdl)));
});
});
+6 -1
View File
@@ -675,7 +675,12 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
SELECT $1, u.id, 'WEIGHT', $2, 'kg', 'MANUAL', $3,
'e2e-sharing-marker', $3, $3
FROM users u WHERE u.username = $4`,
[cuid(), kg, now, username],
// `measured_at` is `timestamp without time zone` and the driver
// serialises a `Date` in the HOST's zone, so a developer machine east
// of UTC seeded this row hours into the FUTURE — where every fold
// whose window ends "now" skips it, and the type stays uncovered.
// Send the instant as UTC, the same shape the app's own writes take.
[cuid(), kg, now.toISOString(), username],
);
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Account hygiene for the blood-glucose journey.
*
* `glucose-unit-journey.spec.ts` counts rows and reads a display unit off
* them, and both verdicts are global: "the list holds three glucose readings,
* all of them in mmol/L" is only true of an account whose glucose history the
* test itself wrote. A reading left by an earlier run, by a Playwright retry,
* or by a sibling worker would add a fourth row and a stale unit, and the
* assertions would read somebody else's data.
*
* The display unit needs the same treatment, and for a sharper reason: the
* journey's whole subject is switching it, so the spec ENDS with the shared
* account on mmol/L. Left there, the next run's mg/dL half would start from
* the answer it is trying to reach — a test that can only pass once is worse
* than no test. `resetGlucose()` puts the column back to NULL (the default
* mg/dL resolution) before every test.
*
* Deletes rather than soft-deletes: the list route filters on `deleted_at IS
* NULL`, but the dashboard tile and the analytics summaries are the other half
* of this journey and a soft-deleted row would still have to be reasoned
* about. These are fixture rows, so they go.
*/
import pg from "pg";
import { E2E_USER } from "./global-setup";
async function withPool<T>(fn: (pool: pg.Pool) => Promise<T>): Promise<T> {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("[glucose-fixture] DATABASE_URL is not set");
const pool = new pg.Pool({ connectionString: url });
try {
return await fn(pool);
} finally {
await pool.end();
}
}
async function getUserId(pool: pg.Pool): Promise<string> {
const res = await pool.query<{ id: string }>(
"SELECT id FROM users WHERE username = $1",
[E2E_USER.username],
);
const id = res.rows[0]?.id;
if (!id) {
throw new Error(
"[glucose-fixture] e2e user not seeded — global-setup must run first",
);
}
return id;
}
/**
* Clear the seeded account's blood-glucose history and put its display unit
* back to the default. Safe to call from every test's `beforeEach`.
*/
export async function resetGlucose(): Promise<void> {
await withPool(async (pool) => {
const userId = await getUserId(pool);
await pool.query(
"DELETE FROM measurements WHERE user_id = $1 AND type = 'BLOOD_GLUCOSE'",
[userId],
);
await pool.query("UPDATE users SET glucose_unit = NULL WHERE id = $1", [
userId,
]);
});
}
+21 -5
View File
@@ -411,12 +411,28 @@ test.describe.serial("FENCE-AC-07 record-fence disk layers", () => {
expect(stored!.epoch).toMatch(/^\d+$/);
expect(stored!.scope).not.toBe("self");
// The EXTERNAL switch: driven from a request context, so the app never
// runs its cache wipe and the entry above survives.
const left = await context.request.post("/api/account/switch", {
data: { accountId: null },
// The EXTERNAL switch: nothing in the app initiates it, so
// `clearOfflineCachesForRecordSwitch` never runs and the entry above
// survives — the positive control below is what proves that.
//
// Sent with the page's own `fetch` rather than through
// `context.request`. Both are outside the app: the switch UI is what runs
// the wipe, and a bare `fetch` is not it (the service worker returns
// without touching any non-GET, so the request reaches the network
// unaltered). What differs is the socket. Every Playwright API context in
// the runner shares one process-wide keep-alive agent, so this POST could
// be handed a connection the server had already closed on its five-second
// idle timeout, and a POST is never replayed on a fresh one — twice this
// line ended the spec with `read ECONNRESET`, on all three attempts.
const left = await page.evaluate(async () => {
const res = await fetch("/api/account/switch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ accountId: null }),
});
return res.status;
});
expect(left.status()).toBe(200);
expect(left).toBe(200);
const me = await context.request.get("/api/auth/me");
const now = (await me.json()).data.recordSession as {
epoch: number;
+24 -6
View File
@@ -282,16 +282,34 @@ test.describe("vaccinations", () => {
test("the list and the capture form carry no serious accessibility violations", async ({
page,
request,
}) => {
// Seed one dose so the list scans a populated surface rather than the empty
// state, then scan both the list and the open capture form.
const seeded = await request.post("/api/vaccinations", {
data: { occurredAt: "2019-05-01T00:00:00.000Z", antigenSlug: "polio" },
});
expect(seeded.status()).toBe(201);
//
// Written from the page, not from a Playwright API context. Every API
// context in the runner shares ONE process-wide keep-alive agent
// (`httpHappyEyeballsAgent`), so a request can be handed a pooled socket the
// server has already closed on its five-second idle timeout — and a POST,
// unlike a GET, is never replayed on a fresh one. This seed died that way
// twice, on this line, on all three attempts each time: `socket hang up`
// and `read ECONNRESET`. The browser opens its own connection and the row
// is still written by the real route, so nothing about the fixture changes
// except which socket carries it.
await page.goto("/vaccinations");
const seededStatus = await page.evaluate(async () => {
const res = await fetch("/api/vaccinations", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
occurredAt: "2019-05-01T00:00:00.000Z",
antigenSlug: "polio",
}),
});
return res.status;
});
expect(seededStatus).toBe(201);
await page.reload();
await expect(
page.locator('[data-slot="vaccination-group"][data-antigen="polio"]'),
).toBeVisible({ timeout: 15_000 });
+11
View File
@@ -208,6 +208,7 @@
"typeGlucosePostprandial": "Glukose (postprandial)",
"typeGlucoseRandom": "Glukose (beliebig)",
"typeGlucoseBedtime": "Glukose (vor dem Schlafen)",
"typeGlucoseUnspecified": "Glukose (ohne Angabe)",
"glucoseClassificationTitle": "Blutzucker — Referenzbereiche (ADA 2024 / DDG)",
"glucoseRow": "{label}: Ø {avg} {unit} (Ziel {rangeMin}–{rangeMax}) — {class}",
"glucoseInTarget": "im Ziel",
@@ -3438,6 +3439,7 @@
"postprandial": "Nach dem Essen",
"random": "Beliebig",
"bedtime": "Vor dem Schlafen",
"unspecified": "Ohne Angabe",
"readingsShort": "{count} Messungen",
"readingsShortOne": "1 Messung"
}
@@ -5353,6 +5355,14 @@
"profileSaved": "Profil gespeichert",
"profilePartiallySaved": "Profil gespeichert, aber {field} konnte nicht aktualisiert werden.",
"profileNothingSaved": "Es wurde nichts gespeichert. Bitte {field} korrigieren und erneut versuchen.",
"profileRejection": {
"tooBig": "Der Wert liegt über dem Bereich, den dieses Feld annimmt.",
"tooSmall": "Der Wert liegt unter dem Bereich, den dieses Feld annimmt.",
"invalidValue": "Das ist keine der Antworten, die dieses Feld annimmt.",
"invalidFormat": "Das ist nicht in einer Form geschrieben, die dieses Feld lesen kann.",
"invalidType": "Der Wert kam nicht in einer Form an, die dieses Feld speichern kann.",
"other": "Dieser Wert wurde nicht angenommen. Bitte prüfen und erneut versuchen."
},
"avatar": {
"title": "Profilfoto",
"description": "Lade ein Foto hoch, das auf deinem eigenen Server gespeichert wird. Es erscheint überall in der App anstelle eines externen Avatar-Dienstes.",
@@ -7593,6 +7603,7 @@
"glucosePostprandial": "Glukose — postprandial",
"glucoseRandom": "Glukose — beliebig",
"glucoseBedtime": "Glukose — vor dem Schlafen",
"glucoseUnspecified": "Glukose — ohne Angabe",
"status": {
"underweight": "Untergewicht",
"normal": "Normal",
+11
View File
@@ -244,6 +244,7 @@
"typeGlucosePostprandial": "Glucose (postprandial)",
"typeGlucoseRandom": "Glucose (random)",
"typeGlucoseBedtime": "Glucose (bedtime)",
"typeGlucoseUnspecified": "Glucose (untagged)",
"glucoseClassificationTitle": "Blood glucose — reference ranges (ADA 2024 / DDG)",
"glucoseRow": "{label}: avg {avg} {unit} (target {rangeMin}–{rangeMax}) — {class}",
"glucoseInTarget": "in target",
@@ -3438,6 +3439,7 @@
"postprandial": "After meals",
"random": "Random",
"bedtime": "Bedtime",
"unspecified": "Untagged",
"readingsShort": "{count} readings",
"readingsShortOne": "1 reading"
}
@@ -5353,6 +5355,14 @@
"profileSaved": "Profile saved",
"profilePartiallySaved": "Profile saved, but {field} could not be updated.",
"profileNothingSaved": "Nothing was saved. Fix {field} and try again.",
"profileRejection": {
"tooBig": "That value is above the range this field accepts.",
"tooSmall": "That value is below the range this field accepts.",
"invalidValue": "That is not one of the answers this field accepts.",
"invalidFormat": "That is not written in a form this field can read.",
"invalidType": "That value did not arrive in a form this field can store.",
"other": "This value was not accepted. Check it and try again."
},
"avatar": {
"title": "Profile photo",
"description": "Upload a photo stored on your own server. It appears across the app in place of an external avatar service.",
@@ -7593,6 +7603,7 @@
"glucosePostprandial": "Glucose — post-meal",
"glucoseRandom": "Glucose — random",
"glucoseBedtime": "Glucose — bedtime",
"glucoseUnspecified": "Glucose — untagged",
"status": {
"underweight": "Underweight",
"normal": "Normal",
+11
View File
@@ -208,6 +208,7 @@
"typeGlucosePostprandial": "Glucosa (posprandial)",
"typeGlucoseRandom": "Glucosa (aleatoria)",
"typeGlucoseBedtime": "Glucosa (antes de dormir)",
"typeGlucoseUnspecified": "Glucosa (sin indicar)",
"glucoseClassificationTitle": "Glucemia — Rangos de referencia (ADA 2024 / DDG)",
"glucoseRow": "{label}: med. {avg} {unit} (objetivo {rangeMin}–{rangeMax}) — {class}",
"glucoseInTarget": "en objetivo",
@@ -3438,6 +3439,7 @@
"postprandial": "Después de comer",
"random": "Aleatorio",
"bedtime": "Antes de dormir",
"unspecified": "Sin indicar",
"readingsShort": "{count} mediciones",
"readingsShortOne": "1 medición"
}
@@ -5353,6 +5355,14 @@
"profileSaved": "Perfil guardado",
"profilePartiallySaved": "Perfil guardado, pero no se pudo actualizar {field}.",
"profileNothingSaved": "No se guardó nada. Corrige {field} e inténtalo de nuevo.",
"profileRejection": {
"tooBig": "El valor está por encima del rango que acepta este campo.",
"tooSmall": "El valor está por debajo del rango que acepta este campo.",
"invalidValue": "Esa no es una de las respuestas que acepta este campo.",
"invalidFormat": "Eso no está escrito en un formato que este campo pueda leer.",
"invalidType": "El valor no llegó en un formato que este campo pueda guardar.",
"other": "No se aceptó este valor. Revísalo e inténtalo de nuevo."
},
"avatar": {
"title": "Foto de perfil",
"description": "Sube una foto que se almacena en tu propio servidor. Aparece en toda la aplicación en lugar de un servicio de avatares externo.",
@@ -7593,6 +7603,7 @@
"glucosePostprandial": "Glucemia — posprandial",
"glucoseRandom": "Glucemia — cualquiera",
"glucoseBedtime": "Glucemia — antes de dormir",
"glucoseUnspecified": "Glucemia — sin indicar",
"status": {
"underweight": "Peso insuficiente",
"normal": "Normal",
+11
View File
@@ -208,6 +208,7 @@
"typeGlucosePostprandial": "Glycémie (postprandiale)",
"typeGlucoseRandom": "Glycémie (indifférente)",
"typeGlucoseBedtime": "Glycémie (avant le coucher)",
"typeGlucoseUnspecified": "Glycémie (non précisée)",
"glucoseClassificationTitle": "Glycémie — Plages de référence (ADA 2024 / DDG)",
"glucoseRow": "{label} : moy. {avg} {unit} (cible {rangeMin}–{rangeMax}) — {class}",
"glucoseInTarget": "dans la cible",
@@ -3438,6 +3439,7 @@
"postprandial": "Après les repas",
"random": "Aléatoire",
"bedtime": "Au coucher",
"unspecified": "Non précisée",
"readingsShort": "{count} mesures",
"readingsShortOne": "1 mesure"
}
@@ -5353,6 +5355,14 @@
"profileSaved": "Profil enregistré",
"profilePartiallySaved": "Profil enregistré, mais {field} n'a pas pu être mis à jour.",
"profileNothingSaved": "Rien n'a été enregistré. Corrigez {field} et réessayez.",
"profileRejection": {
"tooBig": "La valeur dépasse la plage acceptée par ce champ.",
"tooSmall": "La valeur est en dessous de la plage acceptée par ce champ.",
"invalidValue": "Ce n'est pas une des réponses acceptées par ce champ.",
"invalidFormat": "Ce n'est pas écrit dans un format que ce champ peut lire.",
"invalidType": "La valeur n'est pas arrivée dans un format que ce champ peut enregistrer.",
"other": "Cette valeur n'a pas été acceptée. Vérifiez-la et réessayez."
},
"avatar": {
"title": "Photo de profil",
"description": "Téléverse une photo stockée sur ton propre serveur. Elle apparaît dans toute l'application à la place d'un service d'avatar externe.",
@@ -7593,6 +7603,7 @@
"glucosePostprandial": "Glycémie — postprandiale",
"glucoseRandom": "Glycémie — indifférente",
"glucoseBedtime": "Glycémie — avant le coucher",
"glucoseUnspecified": "Glycémie — non précisée",
"status": {
"underweight": "Insuffisance pondérale",
"normal": "Normal",
+11
View File
@@ -208,6 +208,7 @@
"typeGlucosePostprandial": "Glicemia (postprandiale)",
"typeGlucoseRandom": "Glicemia (qualsiasi)",
"typeGlucoseBedtime": "Glicemia (prima di coricarsi)",
"typeGlucoseUnspecified": "Glicemia (non indicata)",
"glucoseClassificationTitle": "Glicemia — Intervalli di riferimento (ADA 2024 / DDG)",
"glucoseRow": "{label}: media {avg} {unit} (obiettivo {rangeMin}–{rangeMax}) — {class}",
"glucoseInTarget": "in obiettivo",
@@ -3438,6 +3439,7 @@
"postprandial": "Dopo i pasti",
"random": "Casuale",
"bedtime": "Prima di dormire",
"unspecified": "Non indicata",
"readingsShort": "{count} misurazioni",
"readingsShortOne": "1 misurazione"
}
@@ -5353,6 +5355,14 @@
"profileSaved": "Profilo salvato",
"profilePartiallySaved": "Profilo salvato, ma non è stato possibile aggiornare {field}.",
"profileNothingSaved": "Non è stato salvato nulla. Correggi {field} e riprova.",
"profileRejection": {
"tooBig": "Il valore supera l'intervallo accettato da questo campo.",
"tooSmall": "Il valore è inferiore all'intervallo accettato da questo campo.",
"invalidValue": "Questa non è una delle risposte accettate da questo campo.",
"invalidFormat": "Non è scritto in un formato che questo campo possa leggere.",
"invalidType": "Il valore non è arrivato in un formato che questo campo possa salvare.",
"other": "Questo valore non è stato accettato. Controllalo e riprova."
},
"avatar": {
"title": "Foto del profilo",
"description": "Carica una foto archiviata sul tuo server. Appare in tutta l'app al posto di un servizio di avatar esterno.",
@@ -7593,6 +7603,7 @@
"glucosePostprandial": "Glicemia — postprandiale",
"glucoseRandom": "Glicemia — qualsiasi",
"glucoseBedtime": "Glicemia — prima di coricarsi",
"glucoseUnspecified": "Glicemia — non indicata",
"status": {
"underweight": "Sottopeso",
"normal": "Normal",
+11
View File
@@ -244,6 +244,7 @@
"typeGlucosePostprandial": "혈당 (식후)",
"typeGlucoseRandom": "혈당 (임의)",
"typeGlucoseBedtime": "혈당 (취침 전)",
"typeGlucoseUnspecified": "혈당 (미지정)",
"glucoseClassificationTitle": "혈당 — 참고 범위 (ADA 2024 / DDG)",
"glucoseRow": "{label}: 평균 {avg} {unit} (목표 {rangeMin}–{rangeMax}) — {class}",
"glucoseInTarget": "목표 범위 내",
@@ -3438,6 +3439,7 @@
"postprandial": "식후",
"random": "임의",
"bedtime": "취침 전",
"unspecified": "미지정",
"readingsShort": "측정값 {count}개",
"readingsShortOne": "측정값 1개"
}
@@ -5353,6 +5355,14 @@
"profileSaved": "프로필을 저장했어요",
"profilePartiallySaved": "프로필을 저장했지만 {field} 항목은 업데이트하지 못했어요.",
"profileNothingSaved": "아무것도 저장하지 못했어요. {field} 항목을 고친 뒤 다시 시도해 주세요.",
"profileRejection": {
"tooBig": "이 항목이 받는 범위보다 큰 값이에요.",
"tooSmall": "이 항목이 받는 범위보다 작은 값이에요.",
"invalidValue": "이 항목이 받는 답변이 아니에요.",
"invalidFormat": "이 항목이 읽을 수 있는 형식이 아니에요.",
"invalidType": "이 항목이 저장할 수 있는 형식으로 오지 않았어요.",
"other": "이 값은 저장하지 못했어요. 확인한 뒤 다시 시도해 주세요."
},
"avatar": {
"title": "프로필 사진",
"description": "직접 운영하는 서버에 저장되는 사진을 올려요. 외부 아바타 서비스 대신 앱 전체에 표시돼요.",
@@ -7593,6 +7603,7 @@
"glucosePostprandial": "혈당 — 식후",
"glucoseRandom": "혈당 — 무작위",
"glucoseBedtime": "혈당 — 취침 전",
"glucoseUnspecified": "혈당 — 미지정",
"status": {
"underweight": "저체중",
"normal": "정상",
+11
View File
@@ -208,6 +208,7 @@
"typeGlucosePostprandial": "Glukoza (po posiłku)",
"typeGlucoseRandom": "Glukoza (dowolna)",
"typeGlucoseBedtime": "Glukoza (przed snem)",
"typeGlucoseUnspecified": "Glukoza (bez oznaczenia)",
"glucoseClassificationTitle": "Glikemia — zakresy referencyjne (ADA 2024 / DDG)",
"glucoseRow": "{label}: śr. {avg} {unit} (cel {rangeMin}–{rangeMax}) — {class}",
"glucoseInTarget": "w celu",
@@ -3438,6 +3439,7 @@
"postprandial": "Po posiłku",
"random": "Losowy",
"bedtime": "Przed snem",
"unspecified": "Bez oznaczenia",
"readingsShort": "{count} pomiarów",
"readingsShortOne": "1 pomiar"
}
@@ -5353,6 +5355,14 @@
"profileSaved": "Profil zapisany",
"profilePartiallySaved": "Profil zapisany, ale nie udało się zaktualizować pola {field}.",
"profileNothingSaved": "Nic nie zostało zapisane. Popraw {field} i spróbuj ponownie.",
"profileRejection": {
"tooBig": "Wartość jest powyżej zakresu, który przyjmuje to pole.",
"tooSmall": "Wartość jest poniżej zakresu, który przyjmuje to pole.",
"invalidValue": "To nie jest jedna z odpowiedzi, które przyjmuje to pole.",
"invalidFormat": "To nie jest zapisane w formacie, który to pole może odczytać.",
"invalidType": "Wartość nie dotarła w formacie, który to pole może zapisać.",
"other": "Ta wartość nie została przyjęta. Sprawdź ją i spróbuj ponownie."
},
"avatar": {
"title": "Zdjęcie profilowe",
"description": "Prześlij zdjęcie przechowywane na własnym serwerze. Pojawia się w całej aplikacji zamiast zewnętrznej usługi awatarów.",
@@ -7593,6 +7603,7 @@
"glucosePostprandial": "Glikemia — po posiłku",
"glucoseRandom": "Glikemia — dowolna",
"glucoseBedtime": "Glikemia — przed snem",
"glucoseUnspecified": "Glikemia — bez oznaczenia",
"status": {
"underweight": "Niedowaga",
"normal": "Normal",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.38.13",
"version": "1.38.14",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+17
View File
@@ -152,8 +152,25 @@ export default defineConfig({
// through stable data-slots, not a mobile layout, so it runs in one
// project.
"vaccinations.spec.ts",
// The blood-glucose journey writes readings to the one shared account
// and then reads GLOBAL verdicts off it — three rows in the list, one
// tile per meal-time context — while also moving that account's
// display-unit column. Two projects mutating it in parallel would
// leave a fourth row in the count and could flip the unit under the
// other one mid-navigation. It proves a flow through stable
// data-slots, not a mobile layout, so it runs in one project.
"glucose-journey.spec.ts",
// Runs only in the service-worker project.
"v137-record-session-fence-offline.spec.ts",
// The Apple Health import journey uploads an archive into the one
// shared account and its refusal control asserts that the account's
// Apple-Health row count did not move. A second project running the
// same journey in parallel would import beside it and flip that
// verdict — and both would be spending the kick-off route's
// three-uploads-per-minute budget on each other. It proves a flow
// through stable data attributes, not a mobile layout, so it runs in
// one project.
"apple-health-import.spec.ts",
],
use: {
// Pixel 5 — Chromium-based mobile profile so CI only needs
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.38.13";
/* @sw-version-fallback */ "v1.38.14";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
+1
View File
@@ -24,6 +24,7 @@ typecheck.
- **`assert-deploy.ts`** — post-deploy `/api/version` assertion.
- **`rotate-encryption-key.ts`** — AES-256-GCM key rotation; see `docs/ops/encryption-key-rotation.md`.
- **`restore-backup.ts`** — restore an off-host AES-GCM backup from an S3-compatible bucket.
- **`repair-apple-health-distance.ts`** — repair walking-distance rows the `export.zip` import stored 1000x too small; dry-run by default, see `docs/ops/apple-health-distance-repair.md`.
- **`repair-intake-anomalies.ts`** — repair historic medication-ledger defects (duplicate dose-slot rows, implausible `taken_at`); dry-run by default, see `docs/ops/intake-repair.md`.
- **`backfill-rollups.ts`**, **`drain-per-sample-cumulative.ts`**, **`backfill-mood-note-column.ts`** — historical data backfills (note: the boot-time `rollup-full-backfill` queue handles new accounts automatically; the CLI is the manual fallback).
- **`seed-demo.ts`** + **`seed-demo-v15.sql`** — populate a demo tenant.
+48 -11
View File
@@ -23,11 +23,20 @@
* pnpm bundle-report # print the report table
* pnpm bundle-report --check # enforce bundle-budget.json (CI gate)
*
* Budgets live in `bundle-budget.json` (repo root). Numbers are KB gzip
* with deliberate headroom over the measured value — the gate exists to
* catch step-change regressions (a ~100 KB catalog, a ~90 KB duplicate
* library), not day-to-day noise. Raise a budget consciously, in the same
* PR that pays the cost, with the reason in the PR body.
* Budgets live in `bundle-budget.json` (repo root). Numbers are KB gzip.
*
* Per-route the file states a MEASURED baseline and one drift allowance
* over it, and a route fails on the delta rather than on a ceiling it is
* already touching. Flat ceilings had been re-stated but never re-based, so
* three routes ended up sitting within a kilobyte of theirs and any
* unrelated dependency bump failed the e2e job on a regression nobody had
* made. The delta is printed on every run, so a route that has crept most of
* the way through its allowance is readable in the log before it is red.
*
* The gate still exists to catch step changes (a ~100 KB statically imported
* catalog, a ~90 KB duplicate library), which are several times the
* allowance. Re-base consciously, in the same PR that pays the cost, with
* the reason written into `$comment`.
*/
import { readFileSync, readdirSync, existsSync } from "node:fs";
@@ -166,14 +175,28 @@ console.log(
console.log(`recharts-fingerprint chunks: ${rechartsChunks}`);
console.log("");
const watched = budget ? Object.keys(budget.routesKbGz ?? {}) : [];
const baselines = budget?.routeBaselineKbGz ?? {};
const allowanceKb = budget?.routeDriftAllowanceKbGz ?? 0;
/** The ceiling a route's measured baseline plus the drift allowance buys it. */
const capFor = (route) =>
baselines[route] == null ? null : baselines[route] + allowanceKb;
const watched = Object.keys(baselines);
const rows = [...routeTotals.entries()]
.filter(([route]) => !watched.length || watched.includes(route))
.sort((a, b) => b[1].gz - a[1].gz);
for (const [route, { gz }] of rows.slice(0, watched.length || 15)) {
const cap = budget?.routesKbGz?.[route];
const baseline = baselines[route];
if (baseline == null) {
console.log(`${fmt(gz).padStart(8)} gz ${route}`);
continue;
}
const delta = kb(gz) - baseline;
const sign = delta >= 0 ? "+" : "−";
console.log(
`${fmt(gz).padStart(8)} gz ${route}${cap ? ` (budget ${cap} KB)` : ""}`,
`${fmt(gz).padStart(8)} gz ${route} (baseline ${baseline} KB, ` +
`${sign}${Math.abs(delta).toFixed(1)} KB of ${allowanceKb} KB allowed, ` +
`cap ${capFor(route)} KB)`,
);
}
@@ -185,16 +208,30 @@ if (!budget) {
process.exit(2);
}
// A budget file that states no baselines gates nothing, and would do it
// quietly — the loop below would simply have nothing to walk. Say so instead.
if (watched.length === 0) {
console.error(
"check-bundle-budget: bundle-budget.json states no routeBaselineKbGz " +
"entries, so no route is gated. Measure the eager routes and write " +
"them in, with a routeDriftAllowanceKbGz over them.",
);
process.exit(2);
}
const failures = [];
for (const [route, capKb] of Object.entries(budget.routesKbGz ?? {})) {
for (const [route, baselineKb] of Object.entries(baselines)) {
const actual = routeTotals.get(route);
if (!actual) {
failures.push(`route ${route} not found in the build output`);
continue;
}
if (kb(actual.gz) > capKb) {
const delta = kb(actual.gz) - baselineKb;
if (delta > allowanceKb) {
failures.push(
`${route}: ${fmt(actual.gz)} gz exceeds the ${capKb} KB budget`,
`${route}: ${fmt(actual.gz)} gz is ${delta.toFixed(1)} KB over the ` +
`${baselineKb} KB baseline, past the ${allowanceKb} KB drift ` +
`allowance (cap ${capFor(route)} KB)`,
);
}
}
+200
View File
@@ -0,0 +1,200 @@
/**
* scripts/repair-apple-health-distance.ts — operator repair for walking
* distances the `export.zip` import stored 1000x too small (issue #944).
*
* THE DEFECT (fixed in code from this release): the `<Record>` mapping read
* the value but never the record's own `unit` attribute, so a walking-distance
* record Apple writes in the account's display unit (`km` on a metric archive,
* `mi` on an imperial one) was folded into the day's total as if the number
* were already metres. Ten years of history read as a couple of metres a day.
*
* WHICH ROWS: only the ones carrying the stamp the archive importer writes —
* `WALKING_RUNNING_DISTANCE` + `APPLE_HEALTH` + provenance
* `EXPORT_XML_SOURCE_MAX` + a `stats:HKQuantityTypeIdentifierDistanceWalking-
* Running:<day>` externalId. Never a guess from "the value looks small". Rows
* a native iOS sync has since overwritten carry `HEALTHKIT_STATISTICS` and are
* correct; rows whose provenance cannot prove an archive origin are left alone.
*
* If the account still has its `export.zip`, RE-IMPORTING it on the fixed build
* is the authoritative repair — the fold UPSERTs the same `stats:` rows with
* the right numbers. This script is for the accounts that no longer have it.
*
* SAFETY:
* - Dry-run by DEFAULT: lists the candidate rows per account and exits.
* - `--apply` writes, one transaction per account, and records an audit row
* in that same transaction. An account carrying that row is skipped, so a
* second run finds nothing.
* - A row whose repaired value would leave the plausibility range refuses
* the WHOLE account: one such row is evidence the rows are not the 1000x
* class (an account re-imported on the fixed build looks exactly like
* this), and its rest days would otherwise be multiplied again.
* - The rollup/status tail runs after the transaction. If it throws, the
* rows are repaired and the derived tier is not; the script names the
* backfill to re-run, the audit row keeps `rollupsRefreshed: false` so a
* later run can still see it, and the exit code is non-zero.
* - `--unit=mi` for an imperial archive (default `km`). The stored row keeps
* no record of the archive's unit — that is the defect — so the operator
* names it; a mismatched unit is why the flag exists rather than a guess.
*
* RUN (never `pnpm tsx` — the standalone image strips tsx):
* pnpm dlx tsx scripts/repair-apple-health-distance.ts # dry run
* pnpm dlx tsx scripts/repair-apple-health-distance.ts --apply
* pnpm dlx tsx scripts/repair-apple-health-distance.ts --unit=mi --apply
*/
import "dotenv/config";
import { prisma } from "@/lib/db";
import {
applyAppleHealthDistanceRepair,
parseArchiveUnit,
planAppleHealthDistanceRepair,
repairFactorFor,
rollupRepairCommand,
} from "@/lib/measurements/repair-apple-health-distance";
function readArchiveUnit(): "km" | "mi" {
const flag = process.argv.find((arg) => arg.startsWith("--unit="));
if (!flag) return "km";
const parsed = parseArchiveUnit(flag.slice("--unit=".length));
if (!parsed) {
throw new Error(`Unsupported --unit value: ${flag}. Use km or mi.`);
}
return parsed;
}
async function main(): Promise<void> {
const apply = process.argv.includes("--apply");
const archiveUnit = readArchiveUnit();
const factor = repairFactorFor(archiveUnit);
const plans = await planAppleHealthDistanceRepair(prisma, { archiveUnit });
if (plans.length === 0) {
console.log(
"No export-imported walking-distance rows found — nothing to repair.",
);
return;
}
console.log(
`Archive unit ${archiveUnit} (x${factor} to metres). ` +
`${plans.length} account(s) carry export-imported walking distances.\n`,
);
let totalRepairable = 0;
let totalUpdated = 0;
let totalSkipped = 0;
let refused = 0;
let wouldRefuse = 0;
let rollupsPending = 0;
for (const plan of plans) {
if (plan.alreadyRepairedAt) {
console.log(
` • ${plan.userId}: already repaired on ` +
`${plan.alreadyRepairedAt.toISOString()} — ${plan.candidateCount} row(s) left alone.`,
);
if (plan.rollupsPending) {
rollupsPending += 1;
console.log(
" ! rollups PENDING from that run: the DAY/WEEK/MONTH/YEAR " +
"buckets and the cached status insights still hold the " +
"pre-repair numbers.",
);
console.log(` re-run: ${rollupRepairCommand(plan.userId)}`);
}
continue;
}
// An account with one out-of-range candidate is refused whole, so its
// rows are not "would be repaired" — counting them promised work the
// --apply run then declined to do.
if (plan.outOfRange.length > 0) wouldRefuse += 1;
else totalRepairable += plan.repairable.length;
const first = plan.repairable[0];
const last = plan.repairable[plan.repairable.length - 1];
console.log(
` • ${plan.userId}: ${plan.repairable.length} row(s)` +
(first && last
? ` from ${first.measuredAt.toISOString().slice(0, 10)} to ` +
`${last.measuredAt.toISOString().slice(0, 10)}`
: "") +
(plan.outOfRange.length > 0
? `, ${plan.outOfRange.length} out of plausible range (skipped)`
: ""),
);
if (first) {
console.log(
` e.g. ${first.id}: ${first.value} m -> ${first.value * factor} m`,
);
}
for (const row of plan.outOfRange) {
console.log(
` ! ${row.id} (${row.measuredAt.toISOString().slice(0, 10)}): ` +
`${row.value} m x ${factor} leaves the plausible range`,
);
}
if (plan.outOfRange.length > 0) {
console.log(
" ! this account will be REFUSED whole — its rows are not in " +
"the count below; see the reason under --apply",
);
}
if (!apply) continue;
const outcome = await applyAppleHealthDistanceRepair(prisma, plan, {
archiveUnit,
});
totalUpdated += outcome.updated;
totalSkipped += outcome.skipped;
if (outcome.refusedReason) {
refused += 1;
console.log(` ✗ refused: ${outcome.refusedReason}`);
continue;
}
console.log(
` ✓ repaired ${outcome.updated} row(s), skipped ${outcome.skipped}`,
);
if (outcome.rollupsPending) {
rollupsPending += 1;
console.error(
` ! ${plan.userId}: rows repaired, but the rollup refresh failed.`,
);
console.error(outcome.rollupError);
console.error(
" The DAY/WEEK/MONTH/YEAR buckets and the cached status " +
"insights still hold the pre-repair numbers.",
);
console.error(` re-run: ${rollupRepairCommand(plan.userId)}`);
}
}
console.log(
apply
? `\nDone. ${totalUpdated} row(s) repaired, ${totalSkipped} skipped, ` +
`${refused} account(s) refused.`
: `\nDry run: ${totalRepairable} row(s) would be repaired` +
(wouldRefuse > 0
? `, ${wouldRefuse} account(s) would be refused (their rows are ` +
"not in that number)"
: "") +
". Read the worked examples above and decide before re-running " +
"with --apply.",
);
if (rollupsPending > 0) {
// The rows are right and the derived tier is not, which no later run of
// this script will notice — it skips a repaired account. Fail loudly.
console.error(
`\n${rollupsPending} account(s) need the rollup backfill re-run ` +
"(commands above).",
);
process.exitCode = 1;
}
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
+820
View File
@@ -0,0 +1,820 @@
#!/usr/bin/env node
/**
* Synthetic post-deploy journey.
*
* `post-publish-verify.yml` proves the published image boots and answers
* `/api/version`. It does not prove that a person can sign in, write a
* reading and read it back — and every defect the v1.38 line kept
* rediscovering sat in exactly that gap: a value written and never read.
* This script walks the journey against a running instance.
*
* Plain Node, no TypeScript and no dependencies, so it runs from a
* workstation, from a runner, or from the deploy host with nothing
* installed but Node 22.
*
* Legs, in order:
* 0. warm — one GET on `/`, `/api/health`, `/api/version` and the
* measurements list. Timings are discarded: the first hit
* on each route after a deploy is a cold start and says
* nothing about the instance's steady state.
* 1. version — `/api/version` reports EXPECTED_VERSION.
* 2. sign in — the password flow the web client uses (`POST
* /api/auth/login`, session cookie, no CSRF token), through
* a cookie jar.
* 3. write — `POST /api/measurements` with an `Idempotency-Key`.
* 4. read — `GET /api/measurements?type=WEIGHT&limit=1` and assert
* the row's id, value, unit and source.
* 5. delete — `DELETE /api/measurements/{id}`, then a direct `GET` on
* the same id that must 404, so the account stays clean and
* the leg proves it rather than trusting a 200.
*
* Legs 3 to 5 run inside a `try`/`finally`: from the write on, the run owns
* a row on a real account, and a red read leg must not leave it behind. The
* `finally` sweeps whatever the delete leg did not — including a row whose
* id the write leg never learned, because the response was lost after the
* server had committed. That row is found again by a marker the write puts
* in `notes`, unique per run.
*
* `BASE_URL` decides where the probe account's password is POSTed, so it is
* checked against a closed in-repo host allowlist and refused with exit 2
* otherwise.
*
* The first failing leg ends the run with a non-zero exit, the response
* status and a short body excerpt. Anything token-shaped in that excerpt
* is redacted before it reaches the log.
*
* Usage:
* BASE_URL=https://review.healthlog.dev \
* SYNTHETIC_USERNAME=… SYNTHETIC_PASSWORD=… EXPECTED_VERSION=v1.38.13 \
* node scripts/synthetic-journey.mjs
*
* node scripts/synthetic-journey.mjs --self-test
*
* `--self-test` runs the whole journey against an in-process mock: once
* against a healthy mock, which must pass, and once per broken leg, each
* of which must fail. A check that cannot fail is worse than no check, so
* the script carries the proof that it can.
*
* The target must be a real instance, not the demo: demo mode blocks every
* mutation, so the write leg would be red for a reason that has nothing to
* do with the deploy. The account must not carry a second factor — the
* password flow stops at the MFA ticket, which the script reports as a
* configuration failure rather than a deploy failure.
*/
import { createServer } from "node:http";
import { randomUUID } from "node:crypto";
const REQUEST_TIMEOUT_MS = 20_000;
/** The cold start after a deploy can take a while; the warm-up waits it out. */
const WARM_TIMEOUT_MS = 90_000;
const BODY_EXCERPT_LIMIT = 400;
/** The reading the journey writes. Distinctive, plausible, and short-lived. */
const PROBE = { type: "WEIGHT", value: 77.7, unit: "kg", source: "MANUAL" };
/**
* The journey signs in with a real account and POSTs that account's password
* to whatever `BASE_URL` names, so `BASE_URL` alone decides where the
* password goes. Anyone able to name the host walks off with a working
* credential; masking guards the log, not the egress. The allowlist is the
* defence, and it is deliberately a const: it changes by a commit and a
* review, never by a form field.
*/
const ALLOWED_HOSTS = [
"review.healthlog.dev",
"healthlog.bombeck.io",
"localhost",
"127.0.0.1",
];
/**
* `null` when the host may receive the probe credentials, otherwise the line
* the caller prints before exiting 2.
*/
function hostRefusal(baseUrl) {
let host;
try {
host = new URL(baseUrl).hostname;
} catch {
return `BASE_URL is not a URL: ${JSON.stringify(baseUrl)}`;
}
if (ALLOWED_HOSTS.includes(host)) return null;
return `BASE_URL host ${JSON.stringify(host)} is not on the allowlist — the probe password is only ever sent to ${ALLOWED_HOSTS.join(", ")}`;
}
/**
* Patterns that must never reach a log line. The first three are this
* project's own token prefixes plus the OpenAI-style key shape the
* idempotency cache also refuses to persist; the last two catch a raw
* cookie value and any long opaque blob (a session id, a bearer token, an
* MFA ticket) that a future response shape might carry.
*/
const REDACTIONS = [
/hlk_[A-Za-z0-9_-]+/g,
/hlr_[A-Za-z0-9_-]+/g,
/hlh_[A-Za-z0-9_-]+/g,
/sk-[A-Za-z0-9_-]+/g,
/(set-cookie|cookie|authorization)\s*[:=]\s*[^\s;,"]+/gi,
/\b[A-Za-z0-9_-]{32,}\b/g,
];
function redact(text) {
let out = String(text ?? "");
for (const pattern of REDACTIONS) out = out.replace(pattern, "[redacted]");
return out;
}
function excerpt(text) {
const flat = redact(text).replace(/\s+/g, " ").trim();
return flat.length > BODY_EXCERPT_LIMIT
? `${flat.slice(0, BODY_EXCERPT_LIMIT)}…`
: flat;
}
/** Raised by a leg; carries the status and body the reporter prints. */
class LegFailure extends Error {
constructor(message, { status, body } = {}) {
super(message);
this.name = "LegFailure";
this.status = status ?? null;
this.body = body ?? null;
}
}
/**
* A cookie jar with exactly the behaviour the journey needs: remember what
* the instance set, send it back. No domain or path matching — every
* request goes to the one origin under test.
*/
function createCookieJar() {
const jar = new Map();
return {
store(response) {
for (const line of response.headers.getSetCookie()) {
const [pair] = line.split(";");
const index = pair.indexOf("=");
if (index <= 0) continue;
const name = pair.slice(0, index).trim();
const value = pair.slice(index + 1).trim();
if (value === "" || value === '""') jar.delete(name);
else jar.set(name, value);
}
},
header() {
if (jar.size === 0) return undefined;
return [...jar].map(([name, value]) => `${name}=${value}`).join("; ");
},
};
}
async function call(baseUrl, path, options = {}) {
const { method = "GET", body, headers = {}, jar, timeoutMs } = options;
const requestHeaders = { accept: "application/json", ...headers };
const cookie = jar?.header();
if (cookie) requestHeaders.cookie = cookie;
if (body !== undefined) requestHeaders["content-type"] = "application/json";
const started = performance.now();
let response;
try {
response = await fetch(new URL(path, baseUrl), {
method,
headers: requestHeaders,
body: body === undefined ? undefined : JSON.stringify(body),
redirect: "manual",
signal: AbortSignal.timeout(timeoutMs ?? REQUEST_TIMEOUT_MS),
});
} catch (error) {
throw new LegFailure(`${method} ${path} did not answer: ${error.message}`);
}
const text = await response.text();
jar?.store(response);
let json = null;
try {
json = JSON.parse(text);
} catch {
/* a non-JSON body is a failure the caller reports with the excerpt */
}
return {
status: response.status,
text,
json,
elapsedMs: performance.now() - started,
};
}
function expectStatus(result, expected, what) {
if (result.status !== expected) {
throw new LegFailure(`${what}: expected HTTP ${expected}`, {
status: result.status,
body: result.text,
});
}
}
function expectValue(actual, expected, what, result) {
if (actual !== expected) {
throw new LegFailure(
`${what}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
{ status: result?.status, body: result?.text },
);
}
}
/** `v1.38.13` and `1.38.13` are the same build; the tag carries the `v`. */
function normaliseVersion(value) {
return String(value ?? "")
.trim()
.replace(/^v/i, "");
}
async function warm(baseUrl, jar) {
for (const path of [
"/",
"/api/health",
"/api/version",
"/api/measurements?type=WEIGHT&limit=1",
]) {
// Status and timing are both deliberately ignored. `/` redirects to the
// login page for an anonymous caller, `/api/health` answers 503 while a
// worker is still coming up, and the measurements list is 401 before the
// sign-in leg. Warming is about paying the compile cost, not asserting.
await call(baseUrl, path, { jar, timeoutMs: WARM_TIMEOUT_MS }).catch(
() => null,
);
}
}
async function assertVersion(baseUrl, jar, expectedVersion) {
const result = await call(baseUrl, "/api/version", { jar });
expectStatus(result, 200, "version");
const served = result.json?.data?.version;
if (normaliseVersion(served) !== normaliseVersion(expectedVersion)) {
throw new LegFailure(
`version: instance serves ${JSON.stringify(served)}, expected ${JSON.stringify(expectedVersion)}`,
{ status: result.status, body: result.text },
);
}
return { detail: `serves ${served}` };
}
async function signIn(baseUrl, jar, username, password) {
const result = await call(baseUrl, "/api/auth/login", {
method: "POST",
jar,
body: { email: username, password },
});
expectStatus(result, 200, "sign in");
if (result.json?.meta?.mfaRequired) {
throw new LegFailure(
"sign in: the account asks for a second factor — the synthetic account must be password-only",
{ status: result.status },
);
}
const userId = result.json?.data?.user?.id;
if (typeof userId !== "string" || userId === "") {
throw new LegFailure("sign in: no user in the response envelope", {
status: result.status,
body: result.text,
});
}
if (!jar.header()) {
throw new LegFailure("sign in: the instance set no session cookie", {
status: result.status,
});
}
return { detail: "session cookie held" };
}
async function writeReading(baseUrl, jar, run) {
const measuredAt = new Date().toISOString();
const result = await call(baseUrl, "/api/measurements", {
method: "POST",
jar,
headers: { "idempotency-key": run.idempotencyKey },
body: {
type: PROBE.type,
value: PROBE.value,
measuredAt,
source: PROBE.source,
// The one handle on this row that exists before the response does.
notes: run.marker,
},
});
expectStatus(result, 201, "write");
const created = result.json?.data;
if (typeof created?.id !== "string" || created.id === "") {
throw new LegFailure("write: no measurement id in the response envelope", {
status: result.status,
body: result.text,
});
}
return { id: created.id, measuredAt, detail: `wrote ${created.id}` };
}
async function readBack(baseUrl, jar, writtenId) {
const result = await call(
baseUrl,
"/api/measurements?type=WEIGHT&limit=1&sortBy=measuredAt&sortDir=desc",
{ jar },
);
expectStatus(result, 200, "read back");
const rows = result.json?.data?.measurements;
if (!Array.isArray(rows) || rows.length === 0) {
throw new LegFailure("read back: the list came back without the reading", {
status: result.status,
body: result.text,
});
}
const row = rows[0];
expectValue(row.id, writtenId, "read back: id", result);
expectValue(row.value, PROBE.value, "read back: value", result);
expectValue(row.unit, PROBE.unit, "read back: unit", result);
expectValue(row.source, PROBE.source, "read back: source", result);
return { detail: `${row.value} ${row.unit} (${row.source})` };
}
async function removeReading(baseUrl, jar, writtenId) {
const result = await call(baseUrl, `/api/measurements/${writtenId}`, {
method: "DELETE",
jar,
});
expectStatus(result, 200, "delete");
// A 200 is the instance's claim, not the outcome. An instance that answers
// 200 and keeps the row would have printed "account left clean" — so read
// the id back and require the 404.
const gone = await call(baseUrl, `/api/measurements/${writtenId}`, { jar });
if (gone.status !== 404) {
throw new LegFailure(
`delete: ${writtenId} still reads back after a 200 delete`,
{ status: gone.status, body: gone.text },
);
}
return { detail: "account left clean" };
}
/**
* The id the write leg never learned. A POST that times out after the server
* committed leaves a row this run owns and cannot name, so ask the instance
* for it: the marker rides in `notes`, is unique per run, and comes back
* decrypted on the list.
*/
async function findLeftover(baseUrl, jar, marker) {
const result = await call(
baseUrl,
"/api/measurements?type=WEIGHT&limit=20&sortBy=measuredAt&sortDir=desc",
{ jar },
);
if (result.status !== 200) return null;
const rows = result.json?.data?.measurements;
if (!Array.isArray(rows)) return null;
return rows.find((row) => row?.notes === marker)?.id ?? null;
}
/**
* Best effort, never throws: a red leg is already the run's verdict, and a
* sweep that threw would replace it. It says out loud what it could not
* remove so the maintainer can.
*/
async function sweep(baseUrl, jar, writtenId, marker, log) {
let id = writtenId;
if (!id) {
id = await findLeftover(baseUrl, jar, marker).catch(() => null);
}
if (!id) return;
const result = await call(baseUrl, `/api/measurements/${id}`, {
method: "DELETE",
jar,
}).catch(() => null);
log(
result?.status === 200
? `sweep removed ${id} after a failed leg`
: `sweep could not remove ${id} — delete it by hand`,
);
}
/**
* Run the journey. Returns `{ ok: true }` or `{ ok: false, leg, error }` —
* the caller decides what an exit code is, which is what lets the self-test
* assert a red run without ending the process.
*/
async function runJourney(config, log = console.log) {
const { baseUrl, username, password, expectedVersion } = config;
const jar = createCookieJar();
// Both known BEFORE the write, so a write whose response never arrives is
// still recoverable: the key folds into the server's idempotency cell, the
// marker into the row's own `notes`.
const run = {
idempotencyKey: randomUUID(),
marker: `synthetic-journey ${randomUUID()}`,
};
const warmStarted = performance.now();
await warm(baseUrl, jar);
log(
`warm ${Math.round(performance.now() - warmStarted)} ms (discarded)`,
);
const runLeg = async (name, execute) => {
const started = performance.now();
try {
const outcome = await execute();
const elapsed = Math.round(performance.now() - started);
log(
`ok ${name.padEnd(10)} ${String(elapsed).padStart(6)} ms ${outcome?.detail ?? ""}`.trimEnd(),
);
return { ok: true };
} catch (error) {
const elapsed = Math.round(performance.now() - started);
log(
`FAILED ${name.padEnd(10)} ${String(elapsed).padStart(6)} ms ${redact(error.message)}`,
);
if (error instanceof LegFailure) {
if (error.status !== null) log(` status ${error.status}`);
if (error.body) log(` body ${excerpt(error.body)}`);
} else {
log(` ${redact(error.stack ?? "")}`);
}
return { ok: false, leg: name, error };
}
};
for (const [name, execute] of [
["version", () => assertVersion(baseUrl, jar, expectedVersion)],
["sign in", () => signIn(baseUrl, jar, username, password)],
]) {
const result = await runLeg(name, execute);
if (!result.ok) return result;
}
// From the write on, the run owns a row on somebody's real account. A red
// read leg is not a reason to leave it there, so the cleanup sits in a
// `finally` and not in a leg that a `return` can skip.
let writtenId = null;
let removed = false;
try {
for (const [name, execute] of [
[
"write",
async () => {
const written = await writeReading(baseUrl, jar, run);
writtenId = written.id;
return written;
},
],
["read back", () => readBack(baseUrl, jar, writtenId)],
[
"delete",
async () => {
const outcome = await removeReading(baseUrl, jar, writtenId);
removed = true;
return outcome;
},
],
]) {
const result = await runLeg(name, execute);
if (!result.ok) return result;
}
} finally {
if (!removed) await sweep(baseUrl, jar, writtenId, run.marker, log);
}
return { ok: true };
}
/* ------------------------------------------------------------------ *
* Self-test: the journey against an in-process instance.
* ------------------------------------------------------------------ */
const SELF_TEST_VERSION = "v9.99.0";
const SELF_TEST_CREDENTIALS = { username: "journey", password: "journey-pass" };
/**
* A mock that answers the five legs. `breaks` names the leg to sabotage,
* which is how the self-test proves each assertion can go red.
*/
function startMockInstance(breaks) {
const rows = new Map();
const server = createServer((request, response) => {
const url = new URL(request.url, "http://mock.invalid");
const send = (status, payload, headers = {}) => {
response.writeHead(status, {
"content-type": "application/json",
...headers,
});
response.end(JSON.stringify(payload));
};
const authenticated = (request.headers.cookie ?? "").includes(
"hl_session=",
);
if (url.pathname === "/api/version") {
const version = breaks === "version" ? "v9.98.0" : SELF_TEST_VERSION;
return send(200, { data: { version, buildSha: null }, error: null });
}
if (url.pathname === "/api/health")
return send(200, { data: {}, error: null });
if (url.pathname === "/") return send(200, { data: null, error: null });
if (url.pathname === "/api/auth/login") {
let body = "";
request.on("data", (chunk) => (body += chunk));
request.on("end", () => {
const parsed = JSON.parse(body || "{}");
if (
parsed.email !== SELF_TEST_CREDENTIALS.username ||
parsed.password !== SELF_TEST_CREDENTIALS.password
) {
return send(401, { data: null, error: "Invalid credentials" });
}
if (breaks === "sign in") {
return send(200, {
data: null,
error: null,
meta: { mfaRequired: true },
});
}
send(
200,
{
data: { user: { id: "user-1", username: parsed.email } },
error: null,
},
{ "set-cookie": "hl_session=mock-session; Path=/; HttpOnly" },
);
});
return;
}
if (!authenticated) return send(401, { data: null, error: "Unauthorized" });
if (url.pathname === "/api/measurements" && request.method === "POST") {
if (breaks === "write") {
return send(500, { data: null, error: "write failed" });
}
let body = "";
request.on("data", (chunk) => (body += chunk));
request.on("end", () => {
const parsed = JSON.parse(body || "{}");
const row = {
id: `m-${rows.size + 1}`,
type: parsed.type,
value: parsed.value,
unit: PROBE.unit,
source: parsed.source,
measuredAt: parsed.measuredAt,
notes: parsed.notes ?? null,
};
rows.set(row.id, row);
// The case the sweep exists for: the server committed and the
// caller never learned the id. Only the marker leads back here.
if (breaks === "write: committed") {
return send(500, { data: null, error: "gateway timed out" });
}
send(201, { data: row, error: null });
});
return;
}
if (url.pathname === "/api/measurements" && request.method === "GET") {
let listed = [...rows.values()];
// The mutation the plan's gate names: the read leg is broken and the
// check has to go red.
if (breaks === "read back: missing") listed = [];
if (breaks === "read back: value") {
listed = listed.map((row) => ({ ...row, value: row.value + 1 }));
}
if (breaks === "read back: unit") {
listed = listed.map((row) => ({ ...row, unit: "lb" }));
}
if (breaks === "read back: source") {
listed = listed.map((row) => ({ ...row, source: "IMPORT" }));
}
return send(200, {
data: { measurements: listed, meta: { total: listed.length } },
error: null,
});
}
if (
url.pathname.startsWith("/api/measurements/") &&
request.method === "DELETE"
) {
if (breaks === "delete") {
return send(404, { data: null, error: "Measurement not found" });
}
// 200 and the row still there — the status alone cannot catch this,
// only the read-back the delete leg does afterwards.
if (breaks === "delete: kept") {
return send(200, { data: { success: true }, error: null });
}
rows.delete(url.pathname.split("/").pop());
return send(200, { data: { success: true }, error: null });
}
if (
url.pathname.startsWith("/api/measurements/") &&
request.method === "GET"
) {
const row = rows.get(url.pathname.split("/").pop());
if (!row) {
return send(404, { data: null, error: "Measurement not found" });
}
return send(200, { data: row, error: null });
}
send(404, { data: null, error: "Not found" });
});
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
const { port } = server.address();
resolve({
baseUrl: `http://127.0.0.1:${port}`,
rows,
close: () => new Promise((done) => server.close(done)),
});
});
});
}
/**
* The allowlist is the only thing standing between a dispatcher and the
* probe account's password, so prove it refuses — including the prefix
* lookalike, which a `startsWith` check would have waved through.
*/
function selfTestHostAllowlist() {
const cases = [
{ url: "https://review.healthlog.dev", refused: false },
{ url: "https://healthlog.bombeck.io/", refused: false },
{ url: "http://localhost:3000", refused: false },
{ url: "http://127.0.0.1:3000", refused: false },
{ url: "https://healthlog.bombeck.io.example.test", refused: true },
{ url: "https://collector.example.test", refused: true },
{ url: "not-a-url", refused: true },
];
let failures = 0;
for (const testCase of cases) {
const refusal = hostRefusal(testCase.url);
const label = `host ${testCase.url}`;
if (Boolean(refusal) === testCase.refused) {
console.log(
`self-test ok ${label.padEnd(44)} → ${refusal ? "refused" : "allowed"}`,
);
} else {
failures += 1;
console.log(
`self-test FAILED ${label.padEnd(44)} → ${refusal ? "refused" : "allowed"}, expected ${testCase.refused ? "refused" : "allowed"}`,
);
}
}
return failures;
}
/**
* The excerpt path is what keeps a token out of a failure log. Prove it
* once, on a body shaped like this project's own key prefix.
*/
function selfTestRedaction() {
const shaped = `hlk_${"a".repeat(24)}`;
const redacted = excerpt(`{"error":"bad token","token":"${shaped}"}`);
const label = "redaction";
if (redacted.includes("hlk_") || !redacted.includes("[redacted]")) {
console.log(`self-test FAILED ${label.padEnd(44)} → ${redacted}`);
return 1;
}
console.log(`self-test ok ${label.padEnd(44)} → ${redacted}`);
return 0;
}
async function selfTest() {
const cases = [
{ breaks: null, expect: "pass", clean: true },
{ breaks: "version", expect: "fail", leg: "version" },
{ breaks: "sign in", expect: "fail", leg: "sign in" },
{ breaks: "write", expect: "fail", leg: "write", clean: true },
// The write committed and the response was lost: no id, and the sweep
// has to find the row again by its marker.
{ breaks: "write: committed", expect: "fail", leg: "write", clean: true },
// The four read cases are also the proof that a red read still deletes:
// `clean` is asserted after the run, and only the `finally` can satisfy
// it once the delete leg has been skipped.
{
breaks: "read back: missing",
expect: "fail",
leg: "read back",
clean: true,
},
{
breaks: "read back: value",
expect: "fail",
leg: "read back",
clean: true,
},
{
breaks: "read back: unit",
expect: "fail",
leg: "read back",
clean: true,
},
{
breaks: "read back: source",
expect: "fail",
leg: "read back",
clean: true,
},
{ breaks: "delete", expect: "fail", leg: "delete" },
{ breaks: "delete: kept", expect: "fail", leg: "delete" },
];
let failures = selfTestHostAllowlist() + selfTestRedaction();
for (const testCase of cases) {
const label = testCase.breaks ?? "nothing broken";
const instance = await startMockInstance(testCase.breaks);
const lines = [];
const outcome = await runJourney(
{
baseUrl: instance.baseUrl,
username: SELF_TEST_CREDENTIALS.username,
password: SELF_TEST_CREDENTIALS.password,
expectedVersion: SELF_TEST_VERSION,
},
(line) => lines.push(line),
);
const leftBehind = instance.rows.size;
await instance.close();
const wanted = testCase.expect === "pass";
const legMatches =
testCase.expect === "pass" || outcome.leg === testCase.leg;
const cleanEnough = !testCase.clean || leftBehind === 0;
if (outcome.ok === wanted && legMatches && cleanEnough) {
console.log(
`self-test ok ${label.padEnd(44)} → ${outcome.ok ? "green" : `red on "${outcome.leg}"`}${testCase.clean ? ", account clean" : ""}`,
);
} else {
failures += 1;
console.log(
`self-test FAILED ${label.padEnd(44)} → ${outcome.ok ? "green" : `red on "${outcome.leg}"`}${cleanEnough ? "" : `, ${leftBehind} row(s) left behind`}, expected ${testCase.expect}${testCase.leg ? ` on "${testCase.leg}"` : ""}`,
);
for (const line of lines) console.log(` | ${line}`);
}
}
if (failures > 0) {
console.error(`\nself-test: ${failures} case(s) behaved wrongly`);
return 1;
}
console.log(
`\nself-test: ${cases.length} journey cases, every leg proven breakable; host allowlist and redaction proven`,
);
return 0;
}
/* ------------------------------------------------------------------ */
function readConfig() {
const missing = [];
const read = (name) => {
const value = process.env[name]?.trim();
if (!value) missing.push(name);
return value ?? "";
};
const config = {
baseUrl: read("BASE_URL").replace(/\/+$/, ""),
username: read("SYNTHETIC_USERNAME"),
password: read("SYNTHETIC_PASSWORD"),
expectedVersion: read("EXPECTED_VERSION"),
};
if (missing.length > 0) {
console.error(`Missing environment: ${missing.join(", ")}`);
console.error(
"Usage: BASE_URL=… SYNTHETIC_USERNAME=… SYNTHETIC_PASSWORD=… EXPECTED_VERSION=… node scripts/synthetic-journey.mjs",
);
process.exit(2);
}
const refusal = hostRefusal(config.baseUrl);
if (refusal) {
console.error(refusal);
process.exit(2);
}
return config;
}
async function main() {
if (process.argv.includes("--self-test")) {
process.exit(await selfTest());
}
const config = readConfig();
console.log(`synthetic journey → ${config.baseUrl}`);
const outcome = await runJourney(config);
if (!outcome.ok) {
console.error(`\nsynthetic journey RED on "${outcome.leg}"`);
process.exit(1);
}
console.log("\nsynthetic journey green");
}
await main();
@@ -0,0 +1,246 @@
/**
* Structural guard on the Apple Health mapping table's unit column
* (issue #944).
*
* `AppleHealthMapping.hkUnit` records the unit HealthLog ASSUMES a
* HealthKit reading arrives in, and `convertToDbUnit` is written against
* that assumption. Apple's `export.xml` does not honour it: every quantity
* `<Record>` carries the account's own display unit, so a metric archive
* reports walking distance in `km` and an imperial one in `mi`. For ten
* years those readings were stored as if the number were already metres —
* the whole history understated by a factor of a thousand — because the
* mapper accepted `input.unit` and never read it.
*
* So an entry now has exactly two legal shapes:
*
* 1. its `hkUnit` is one `convertHkValue()` knows a factor for, in which
* case `mapAppleHealthEntry()` converts a differing record unit into
* it before `convertToDbUnit` runs; or
* 2. it carries a written `unitFixedReason` saying why no unit attribute
* Apple could write would change the reading (a dimensionless count,
* a pinned event, a logarithmic dB level, a compound rate).
*
* The conversion is opt-in per caller (`convertRecordUnit`), and only the
* archive path opts in: `POST /api/measurements/batch` documents its `unit`
* as captured-not-validated and nothing pins the native client's strings.
* The last block below holds that boundary, so the batch contract cannot be
* widened by accident.
*
* The reason arm alone would be a guard that cannot fail: an entry could
* declare a convertible unit while the mapper quietly ignored it. The
* behavioural block below therefore feeds every convertible entry a
* reading in a SIBLING unit and asserts the stored number moved by the
* documented factor — so deleting the conversion turns this guard red,
* not just the one reproduction test.
*
* A tripwire, not a proof: a reviewer who waves through a wrong factor
* defeats it.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
APPLE_HEALTH_TYPE_MAP,
mapAppleHealthEntry,
} from "@/lib/measurements/apple-health-mapping";
import {
convertHkValue,
isConvertibleHkUnit,
} from "@/lib/measurements/hk-units";
const ENTRIES = Object.values(APPLE_HEALTH_TYPE_MAP);
/**
* Units from every family the table touches. The behavioural block picks
* the first one that converts into an entry's `hkUnit` and actually moves
* the number, so a new family only needs one representative here.
*/
const SIBLING_UNITS = [
"km",
"mi",
"cm",
"lb",
"st",
"kJ",
"cal",
"degF",
"K",
"mmol<180.156>/L",
"kPa",
"km/hr",
"hr",
"ms",
];
function siblingUnitFor(hkUnit: string): string | null {
for (const candidate of SIBLING_UNITS) {
const converted = convertHkValue(1, candidate, hkUnit);
if (converted !== null && converted !== 1) return candidate;
}
return null;
}
describe("Apple Health mapping — the record's own unit (issue #944)", () => {
it("examines the whole mapping table", () => {
// An empty match set is the failure mode this repo keeps rediscovering:
// a guard is green because it matched nothing, not because nothing is
// wrong. Pin the floor.
expect(ENTRIES.length).toBeGreaterThan(40);
});
it.each(ENTRIES)(
"$hkIdentifier either converts its record unit or says why it cannot",
(mapping) => {
if (isConvertibleHkUnit(mapping.hkUnit)) {
expect(mapping.unitFixedReason).toBeUndefined();
return;
}
expect(
mapping.unitFixedReason,
`${mapping.hkIdentifier} pins hkUnit "${mapping.hkUnit}" with no conversion and no written reason`,
).toBeTypeOf("string");
// Long enough to be a sentence rather than a shrug.
expect(mapping.unitFixedReason!.length).toBeGreaterThan(40);
},
);
const convertible = ENTRIES.filter((mapping) =>
isConvertibleHkUnit(mapping.hkUnit),
);
it("has convertible entries to exercise", () => {
expect(convertible.length).toBeGreaterThan(10);
});
it.each(convertible)(
"$hkIdentifier honours a sibling unit on the record itself",
(mapping) => {
const sibling = siblingUnitFor(mapping.hkUnit);
expect(
sibling,
`no sibling unit listed for the family of "${mapping.hkUnit}"`,
).not.toBeNull();
const base = {
hkIdentifier: mapping.hkIdentifier,
value: 2,
startDate: "2026-05-14T08:00:00.000Z",
endDate: "2026-05-14T08:30:00.000Z",
};
const native = mapAppleHealthEntry(
{ ...base, unit: mapping.hkUnit },
{ convertRecordUnit: true },
);
const foreign = mapAppleHealthEntry(
{ ...base, unit: sibling! },
{ convertRecordUnit: true },
);
expect(native).not.toBeNull();
expect(foreign).not.toBeNull();
const expected = mapping.convertToDbUnit(
convertHkValue(2, sibling!, mapping.hkUnit)!,
);
expect(foreign!.value).toBeCloseTo(expected, 9);
expect(foreign!.value).not.toBeCloseTo(native!.value, 9);
},
);
it("keeps a unit it cannot place from rescaling the reading", () => {
const out = mapAppleHealthEntry(
{
hkIdentifier: "HKQuantityTypeIdentifierDistanceWalkingRunning",
value: 1234,
unit: "furlong",
startDate: "2026-05-14T08:00:00.000Z",
endDate: "2026-05-14T08:30:00.000Z",
},
{ convertRecordUnit: true },
);
expect(out?.value).toBe(1234);
});
it("leaves the batch path's readings alone whatever unit they name", () => {
// The batch contract: `unit` is captured, never read. Every convertible
// entry has to behave that way by DEFAULT, or a native client stamping
// the person's display unit silently rescales their history.
for (const mapping of convertible) {
const sibling = siblingUnitFor(mapping.hkUnit);
if (!sibling) continue;
const base = {
hkIdentifier: mapping.hkIdentifier,
value: 2,
startDate: "2026-05-14T08:00:00.000Z",
endDate: "2026-05-14T08:30:00.000Z",
};
const declared = mapAppleHealthEntry({ ...base, unit: mapping.hkUnit });
const foreign = mapAppleHealthEntry({ ...base, unit: sibling });
expect(declared).not.toBeNull();
expect(
foreign!.value,
`${mapping.hkIdentifier} rescaled a batch reading from "${sibling}"`,
).toBeCloseTo(declared!.value, 9);
}
});
it("has only the archive path opting into the conversion", () => {
// A grep, not a proof — but the wire change this pins is exactly the
// kind that lands as a one-word argument in an unrelated diff.
const optIn = "convertRecordUnit: true";
const archive = readFileSync(
join(process.cwd(), "src/lib/measurements/import-apple-health-export.ts"),
"utf8",
);
expect(archive).toContain(optIn);
const batch = readFileSync(
join(process.cwd(), "src/app/api/measurements/batch/route.ts"),
"utf8",
);
expect(
batch,
"the batch route opted into record-unit conversion",
).not.toContain("convertRecordUnit");
});
it("leaves the km/mi factors in one shared module", () => {
// The workout path used to carry its own copy of the km/mi branch; the
// duplicate is what let the `<Record>` path stay wrong for ten years.
//
// `* 1000` alone is not the offence — these files also turn seconds into
// milliseconds — so a rescale only counts when it stands in a LENGTH
// context: a distance identifier on the same line, or within the five
// lines above it (which covers a rescale in the body of a
// distance-named helper). The limit is the usual one for a matcher of
// this shape: a length conversion inside a helper named nothing like a
// distance, more than five lines below its own signature, slips it.
const shared = join(process.cwd(), "src/lib/measurements/hk-units.ts");
const owners = [
"src/lib/measurements/apple-health-mapping.ts",
"src/lib/measurements/import-apple-health-export.ts",
];
expect(readFileSync(shared, "utf8")).toContain("1609.344");
const RESCALE = /\*\s*1_?000(?!\d)/;
const LENGTH = /distance|metre|meter|\bkm\b/i;
for (const rel of owners) {
const source = readFileSync(join(process.cwd(), rel), "utf8");
expect(source, `${rel} re-implements the mile factor`).not.toContain(
"1609.344",
);
const lines = source.split("\n");
const offenders = lines
.map((line, i) => ({ line, i }))
.filter(
({ line, i }) =>
RESCALE.test(line) &&
lines.slice(Math.max(0, i - 5), i + 1).some((l) => LENGTH.test(l)),
)
.map(({ line, i }) => `${rel}:${i + 1}: ${line.trim()}`);
expect(
offenders,
`${rel} re-implements a length factor — the km/mi conversion belongs in hk-units.ts`,
).toEqual([]);
}
});
});
@@ -19,6 +19,18 @@
* `.catch()` / `.then()` whose handler writes to the console — the shape that
* actually produced the failure. A background path that logs some other way,
* or that logs from a callee rather than from the handler, is invisible to it.
*
* It was narrower than that until the window below was widened. An
* eight-line handler window and a five-line registration preamble were both
* measured against the sites that existed when the guard was written, and
* both cut through real code: the console write in `mood-rollups.ts` sits
* nine lines into its handler, so the sweep never saw the one detached
* `Promise.all` in the tree that was genuinely unregistered, and the
* registration in `fire-and-forget.ts` sits BELOW its handler rather than
* above it, so a site that is correct read as a violation. The window now
* spans a plausible handler body and registration counts wherever it wraps
* the statement — above the handle or on the line that files the settled
* result.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
@@ -57,6 +69,37 @@ function sourceFiles(): string[] {
.filter((rel) => !rel.endsWith(".test.ts") && !rel.endsWith(".test.tsx"));
}
/**
* How far past the `.catch(` / `.then(` line a handler's console write still
* counts as this handler's. Twenty lines covers every handler body in the
* tree with room to spare; eight did not.
*/
const HANDLER_WINDOW = 20;
const registers = (text: string) => /trackBackgroundTask\(/.test(text);
/**
* The source lines that open a call still unclosed at the start of `line` —
* the chain of enclosing calls the handler sits inside, innermost last.
*
* Paren counting is a coarse reading of TypeScript: a `(` inside a string or a
* comment counts like any other. That costs nothing here, because the question
* asked of the result is only whether one of those lines names
* `trackBackgroundTask`, and a stray paren can at worst widen the chain by a
* line that does not.
*/
function enclosingOpenCallLines(source: string, line: number): string[] {
const lines = source.split("\n");
const open: number[] = [];
for (let i = 0; i < line - 1; i += 1) {
for (const ch of lines[i]) {
if (ch === "(") open.push(i);
else if (ch === ")") open.pop();
}
}
return open.map((i) => lines[i]);
}
/** Lines starting a `.catch()` / `.then()` whose handler writes to the console. */
function consoleWritingHandlers(): { file: string; line: number }[] {
const found: { file: string; line: number }[] = [];
@@ -64,7 +107,7 @@ function consoleWritingHandlers(): { file: string; line: number }[] {
const lines = readFileSync(join(SRC, rel), "utf8").split("\n");
lines.forEach((line, index) => {
if (!/\.(catch|then)\(/.test(line)) return;
const handler = lines.slice(index, index + 8).join("\n");
const handler = lines.slice(index, index + HANDLER_WINDOW).join("\n");
if (!/console\.(log|warn|error|info|debug|trace)\(/.test(handler)) return;
found.push({ file: rel, line: index + 1 });
});
@@ -149,11 +192,18 @@ describe("background-task settle contract", () => {
const unregistered = sites.filter(({ file, line }) => {
if (UNREGISTERED_BY_DESIGN.has(file)) return false;
const lines = readFileSync(join(SRC, file), "utf8").split("\n");
// The registration wraps the handle, so it opens on one of the few lines
// above the `.catch(` / `.then(` the sweep matched.
const preamble = lines.slice(Math.max(0, line - 5), line).join("\n");
return !/trackBackgroundTask\(/.test(preamble);
const source = readFileSync(join(SRC, file), "utf8");
// Registered either by wrapping the handle — `trackBackgroundTask(` is
// then one of the calls still open where the handler starts, however
// many lines above that is — or by filing the settled handle just after
// the handler closes. Both shapes are in the tree; a fixed window above
// the match saw neither reliably.
if (enclosingOpenCallLines(source, line).some(registers)) return false;
const lines = source.split("\n");
const trailer = lines
.slice(line, line + HANDLER_WINDOW + 5)
.filter((l) => registers(l));
return trailer.length === 0;
});
expect(
@@ -0,0 +1,197 @@
/**
* Structural guard — a dashboard tile may not be gated on a closed enum
* whose column is nullable.
*
* The Blood Glucose tile decided whether to paint by filtering the four
* `GlucoseContext` members against a per-context summary record and asking
* whether any of them held readings. Every named context is a legal value of
* the column, so the list read as exhaustive — but `glucose_context` is
* nullable, and a meter synced through Apple Health writes no meal-time
* metadata at all. An account with one hundred percent untagged readings
* therefore matched no bucket, and the tile was omitted with the module on,
* the layout toggle on, and a reading from today. Nothing warned: every
* behavioural test in the suite seeded a tagged reading, so the missing arm
* was invisible from the outside (#943).
*
* What the guard asserts: wherever a dashboard tile surface derives presence
* from a list of enum buckets, the list must carry a null / unknown arm — or
* the call site must write down why it cannot. It matches on the list
* EXPRESSION, following one hop through a named constant, because the fix for
* this class is always the same: the untagged bucket joins the list the
* eligibility loop already walks.
*
* Its limits, written down so a later reader does not over-trust it: it sees
* presence tests spelled with `count`, and a bucket list that is either an
* inline literal or a single named constant. An eligibility gate that counted
* rows some other way, or assembled its bucket list at runtime, would slip
* past. The `detect()` self-controls below exist so a refactor that silently
* stops matching anything fails here rather than going quiet.
*/
import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
/**
* A bucket list that carries one of these tokens has an arm for rows the
* enum cannot name. Matched against the list expression's source text, so a
* shared constant (`GLUCOSE_CONTEXT_BUCKETS = [...NAMED, UNSPECIFIED]`)
* satisfies it through its own name.
*/
const UNTAGGED_ARM = /UNSPECIFIED|UNKNOWN|UNTAGGED|OTHER|NONE/;
/** The written-reason escape hatch, with a non-empty reason after it. */
const WRITTEN_REASON = /tile-eligibility-exempt:\s*\S/;
/** Dashboard surfaces that decide whether a tile paints. */
const SURFACES = [
"src/app/page-client.tsx",
...listSourceFiles("src/components/dashboard"),
...listSourceFiles("src/lib/dashboard"),
];
function listSourceFiles(dir: string): string[] {
const abs = join(process.cwd(), dir);
return readdirSync(abs)
.filter((name) => /\.tsx?$/.test(name))
.map((name) => join(dir, name))
.filter((rel) => statSync(join(process.cwd(), rel)).isFile());
}
interface Finding {
/** The bucket-list expression as written at the eligibility site. */
list: string;
/** 1-based line of the eligibility site. */
line: number;
}
/**
* Iteration over a bucket list: `for (const x of LIST)` or
* `LIST.filter(…)` / `.some(…)`. The list is either an inline array literal
* or an identifier.
*/
const ITERATION =
/(?:for\s*\(\s*(?:const|let)\s+[^)]*?\bof\s+(\[[^\]]*\]|[A-Za-z_$][\w$]*)\s*\))|(?:(\[[^\]]*\]|[A-Za-z_$][\w$]*)\s*\.\s*(?:filter|some|flatMap)\s*\()/g;
/** A presence test — "does this bucket hold any readings". */
const PRESENCE = /\bcount\b/;
/** How far past the iteration head a presence test still belongs to it. */
const BODY_WINDOW = 500;
/**
* Every eligibility site in `source` whose bucket list has no untagged arm
* and no written reason. `resolve` supplies the initializer text of a named
* constant so the guard can follow one hop out of the file.
*/
export function detect(
source: string,
resolve: (name: string) => string | null,
): Finding[] {
const findings: Finding[] = [];
ITERATION.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = ITERATION.exec(source)) !== null) {
const list = match[1] ?? match[2];
if (!list) continue;
const body = source.slice(match.index, match.index + BODY_WINDOW);
if (!PRESENCE.test(body)) continue;
// Only enum-shaped lists are in scope: an inline literal of UPPER_SNAKE
// members, or an UPPER_SNAKE constant name.
const inline = list.startsWith("[");
const members = inline ? list.match(/"[A-Z][A-Z0-9_]*"/g) : null;
if (inline && (members?.length ?? 0) < 2) continue;
if (!inline && !/^[A-Z][A-Z0-9_]*$/.test(list)) continue;
const expression = inline ? list : (resolve(list) ?? list);
if (UNTAGGED_ARM.test(expression)) continue;
const before = source.slice(Math.max(0, match.index - 400), match.index);
if (WRITTEN_REASON.test(before)) continue;
findings.push({
list,
line: source.slice(0, match.index).split("\n").length,
});
}
return findings;
}
/** Initializer text of `export const NAME = …` / `const NAME = …` in `src/`. */
function resolveConstant(name: string): string | null {
for (const file of CONSTANT_SOURCES) {
const source = readFileSync(join(process.cwd(), file), "utf8");
const declaration = new RegExp(
`(?:export\\s+)?const\\s+${name}\\s*(?::[^=]+)?=\\s*(\\[[\\s\\S]*?\\])`,
).exec(source);
if (declaration) return declaration[1];
}
return null;
}
/** Files a dashboard surface may import a bucket list from. */
const CONSTANT_SOURCES = ["src/lib/glucose.ts", ...SURFACES];
describe("detect() — the matcher itself", () => {
it("flags the shape that shipped: four enum members, no null arm", () => {
const shipped = `
const contexts = ["FASTING", "POSTPRANDIAL", "RANDOM", "BEDTIME"];
const present = ["FASTING", "POSTPRANDIAL", "RANDOM", "BEDTIME"].filter(
(ctx) => (byContext[ctx]?.count ?? 0) > 0,
);
`;
const findings = detect(shipped, () => null);
expect(findings).toHaveLength(1);
expect(findings[0].list).toContain("FASTING");
});
it("accepts a list that carries an untagged arm", () => {
const fixed = `
for (const bucket of GLUCOSE_CONTEXT_BUCKETS) {
const summary = byContext?.[bucket];
if (!summary || summary.count <= 0) continue;
}
`;
const resolve = () => '["FASTING", "BEDTIME", GLUCOSE_CONTEXT_UNSPECIFIED]';
expect(detect(fixed, resolve)).toEqual([]);
// …and rejects the same site when the constant has no arm, so the pass
// above is the arm's doing and not the resolver's.
expect(detect(fixed, () => '["FASTING", "BEDTIME"]')).toHaveLength(1);
});
it("accepts a written reason", () => {
const exempt = `
// tile-eligibility-exempt: sleep stages are never null on this path.
for (const stage of ["DEEP", "REM"]) {
if (byStage[stage].count > 0) return true;
}
`;
expect(detect(exempt, () => null)).toEqual([]);
});
it("ignores lists that are not presence tests", () => {
const unrelated = `
const labels = ["WEIGHT", "PULSE"].map((type) => LABELS[type]);
`;
expect(detect(unrelated, () => null)).toEqual([]);
});
});
describe("dashboard tile eligibility", () => {
it("scans the surfaces it claims to scan", () => {
// An empty match set must fail rather than read as clean: the guard is
// worthless the day its matchers stop finding the real call sites.
expect(SURFACES.length).toBeGreaterThan(5);
const sited = SURFACES.filter((file) =>
PRESENCE.test(readFileSync(join(process.cwd(), file), "utf8")),
);
expect(sited.length).toBeGreaterThan(0);
});
it("computes no tile's eligibility from an enum without a null arm", () => {
const offenders: string[] = [];
for (const file of SURFACES) {
const source = readFileSync(join(process.cwd(), file), "utf8");
for (const finding of detect(source, resolveConstant)) {
offenders.push(`${file}:${finding.line} — ${finding.list}`);
}
}
expect(offenders).toEqual([]);
});
});
@@ -34,6 +34,11 @@ const E2E_DIR = resolve(__dirname, "../../e2e");
* wrong question and should be rethought rather than extended.
*/
const ALLOWLIST: ReadonlyArray<{ file: string; test: string; why: string }> = [
{
file: "vaccinations.spec.ts",
test: "the list and the capture form carry no serious accessibility violations",
why: "The evaluate after the goto reads no DOM: it issues the seed POST from the page's own origin so the row is written over the browser's socket rather than the runner's shared keep-alive agent, which handed POSTs to sockets the server had already closed. The scan that follows is gated on the reloaded list.",
},
{
file: "ipad-viewport.spec.ts",
test: "no horizontal overflow and full-width content on",
@@ -293,7 +293,15 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
},
"/api/fitbit/connect": { kind: "browserHandoff", methods: ["GET"] },
"/api/google-health/connect": { kind: "browserHandoff", methods: ["GET"] },
"/api/nightscout/connect": { kind: "browserHandoff", methods: ["POST"] },
// `/api/nightscout/connect` sat here as a `browserHandoff` and is published
// now. Nightscout has no OAuth step and no authorise page: the route is an
// authenticated JSON POST that Zod-parses a URL and a token, probes the
// instance and encrypts the pair onto the row. It was the only POST among
// eight `connect` entries and the only one whose siblings — status, test,
// disconnect — were all published, which is what makes it read as an entry
// that inherited a neighbour's reason rather than earned its own. The seven
// GET handoffs below keep it: each of those really does end at a provider's
// authorise page.
"/api/oura/connect": { kind: "browserHandoff", methods: ["GET"] },
"/api/polar/connect": { kind: "browserHandoff", methods: ["GET"] },
"/api/strava/connect": { kind: "browserHandoff", methods: ["GET"] },
@@ -0,0 +1,135 @@
/**
* Every client that writes the profile has to read the answer.
*
* `applyProfileUpdate` writes field by field: a body where some fields
* validate and some do not is answered with 200 and a `rejectedFields`
* list naming what was dropped. A client that treats any 200 as a clean
* save discards the person's correction and says nothing — which is
* exactly what the onboarding baseline step did, for as long as the
* route has behaved this way.
*
* That is a whole-class defect, not one screen's bug, so the guard is
* on the class: enumerate every surface that writes to either profile
* route and require each one either to read `rejectedFields`, or to
* send a body the partial arm cannot reach.
*
* Its limit, written down so nobody mistakes it for a proof: it matches
* a URL literal next to a mutating method. A future submitter that
* builds its path by concatenation, or routes through a helper that
* takes the path as an argument, would not be seen. The set below is
* frozen so that adding one has to be a deliberate edit here.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
/** The two routes that answer with `rejectedFields`. */
const PROFILE_ROUTES = ["/api/auth/profile", "/api/user/profile"];
function sourceFiles(): string[] {
return walkSourceFiles(SRC, { floor: 3000 })
.filter((p) => !p.startsWith("generated/"))
.filter((p) => !p.startsWith("app/api/"))
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
.sort();
}
function read(rel: string): string {
return readFileSync(join(SRC, rel), "utf8");
}
/**
* A file counts as a submitter when a profile-route literal and a
* mutating method literal both appear within one request expression —
* approximated as the 400 characters following the URL, which covers
* the `fetch(url, { method, headers, body })` shape every caller in the
* tree uses.
*/
function submitters(): string[] {
const found: string[] = [];
for (const rel of sourceFiles()) {
const text = read(rel);
for (const route of PROFILE_ROUTES) {
let at = text.indexOf(`"${route}"`);
while (at !== -1) {
const window = text.slice(at, at + 400);
if (/method:\s*"(PUT|PATCH)"/.test(window)) {
found.push(rel);
at = -1;
break;
}
at = text.indexOf(`"${route}"`, at + 1);
}
if (found[found.length - 1] === rel) break;
}
}
return [...new Set(found)].sort();
}
/**
* Submitters whose body carries exactly ONE field. The partial arm
* needs at least one field to accept alongside at least one to refuse,
* so a one-field body is answered either with a clean 200 or with the
* 422 `profile.update.nothingSaved` — never with `rejectedFields`.
* These already surface the 422 as a save error.
*
* The claim is not taken on trust: the body literal is counted below.
*/
const SINGLE_FIELD_SUBMITTERS = [
"components/settings/date-format-select.tsx",
"components/settings/mood-reminder-card.tsx",
"components/settings/time-format-select.tsx",
];
/** Submitters that send several fields and must read the answer. */
const MULTI_FIELD_SUBMITTERS = [
"components/onboarding/baseline-form-utils.ts",
"components/settings/account-section/index.tsx",
];
describe("every profile submitter answers a rejected field", () => {
it("finds the submitters at all", () => {
// A guard that matches nothing is green for the wrong reason.
expect(submitters().length).toBeGreaterThan(0);
});
it("keeps the submitter set frozen", () => {
expect(submitters()).toEqual(
[...SINGLE_FIELD_SUBMITTERS, ...MULTI_FIELD_SUBMITTERS].sort(),
);
});
it.each(MULTI_FIELD_SUBMITTERS)("%s reads rejectedFields", (rel) => {
const text = read(rel);
expect(text).toMatch(/rejectedFields/);
// And renders them per field, rather than only counting them. The
// matcher demands a CALL: an import of the helper next to a body
// that never calls it is the shape this whole guard exists to
// catch, and matching the bare name would pass on it.
const calls = [...text.matchAll(/describeRejectedProfileFields\s*\(/g)];
expect(calls.length).toBeGreaterThan(0);
});
it.each(SINGLE_FIELD_SUBMITTERS)("%s sends exactly one field", (rel) => {
const text = read(rel);
const bodies = [
...text.matchAll(/body:\s*JSON\.stringify\(\{([^}]*)\}\)/g),
];
expect(bodies.length).toBeGreaterThan(0);
for (const [, inner] of bodies) {
const keys = inner
.split(",")
.map((part) => part.trim())
.filter(Boolean);
// One key, so there is never a valid sibling to save alongside an
// invalid field — the arm that answers with `rejectedFields`
// cannot be reached from here.
expect(keys).toHaveLength(1);
}
});
});
@@ -56,9 +56,19 @@ const TARGET_SURFACES = [
* the same failure the target block guards against — a converted field
* over a canonical save silently rewrites the stored height.
*/
/**
* One entry per surface. A surface is a group of files because the
* onboarding step keeps the preference and the adapter where the save
* happens and renders the control from its own inputs file — the three
* conditions below are asserted across the group, not per file, so
* splitting a form does not read as an un-wire.
*/
const HEIGHT_SURFACES = [
join(SRC, "components", "onboarding", "baseline-form.tsx"),
join(SRC, "components", "settings", "account-section", "index.tsx"),
[
join(SRC, "components", "onboarding", "baseline-form.tsx"),
join(SRC, "components", "onboarding", "baseline-fields.tsx"),
],
[join(SRC, "components", "settings", "account-section", "index.tsx")],
];
const HEIGHT_CONTROL = join(
@@ -153,21 +163,25 @@ describe("unit-preference display guard", () => {
// from one form and it falls back to centimetres while its sibling
// shows feet and inches.
const offenders: string[] = [];
for (const file of HEIGHT_SURFACES) {
const src = readFileSync(file, "utf8");
for (const files of HEIGHT_SURFACES) {
const surface = files.join(" + ");
const src = files.map((file) => readFileSync(file, "utf8")).join("\n");
if (!src.includes("useUnitDisplay()")) {
offenders.push(`${file}: no preference hook`);
offenders.push(`${surface}: no preference hook`);
}
if (!src.includes("resolveHeightUnitAdapter")) {
offenders.push(`${file}: no height adapter`);
offenders.push(`${surface}: no height adapter`);
}
if (!src.includes("HeightFieldControl")) {
offenders.push(`${file}: hand-rolled height input`);
// Word-bounded: a renamed local wrapper whose name merely starts
// with the control's satisfies a substring check while rendering
// something else entirely.
if (!/\bHeightFieldControl\b/.test(src)) {
offenders.push(`${surface}: hand-rolled height input`);
}
// The canonical centimetre guardrails must come from the
// adapter's inward-rounded bounds, never from a literal.
if (/min=\{50\}|max=\{300\}/.test(src)) {
offenders.push(`${file}: hardcoded centimetre guardrail`);
offenders.push(`${surface}: hardcoded centimetre guardrail`);
}
}
expect(offenders).toEqual([]);
@@ -0,0 +1,59 @@
/**
* Every job that runs the unit suite installs the browser the suite needs.
*
* `health-score-card-geometry.test.tsx` renders the real hero band in a real
* Chromium and measures the boxes. It used to catch its own launch failure and
* pass, so for months it measured nothing in CI; since v1.38 it throws when
* `CI` is set instead, which is right — and which turns a job that runs
* `pnpm test` without a browser from silently useless into loudly red.
*
* That is exactly what happened. The install step was added to the quality job
* in `security.yml` and nowhere else, and `dependabot-auto-merge.yml` runs the
* same suite: every dependency bump failed on a launch error that said nothing
* about the bump. One workflow was fixed, one class was not.
*
* The sweep is textual and file-scoped, which is its limit: a workflow with two
* jobs, only one of which installs the browser, satisfies it. It catches the
* thing that actually went wrong — a whole workflow running the suite with no
* browser anywhere in it.
*/
import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
const WORKFLOWS = join(process.cwd(), ".github", "workflows");
/** `run: pnpm test` — the whole unit suite, geometry check included. */
const RUNS_UNIT_SUITE = /^\s*(?:-\s*)?run:\s*pnpm test\s*$/m;
const INSTALLS_BROWSER = /playwright install --only-shell chromium/;
describe("the browser the unit suite measures with", () => {
it("is installed by every workflow that runs the suite", () => {
const files = readdirSync(WORKFLOWS).filter((f) => f.endsWith(".yml"));
expect(
files.length,
"no workflow files found — the walk is broken, not the tree clean",
).toBeGreaterThanOrEqual(5);
const runners = files.filter((f) =>
RUNS_UNIT_SUITE.test(readFileSync(join(WORKFLOWS, f), "utf8")),
);
// An empty match set would agree with an empty violation list. It is two
// workflows today; pinned at one so removing a job is not a guard failure,
// but a matcher that stops matching is.
expect(
runners.length,
"no workflow runs `pnpm test` — the matcher stopped matching",
).toBeGreaterThanOrEqual(1);
const missing = runners.filter(
(f) => !INSTALLS_BROWSER.test(readFileSync(join(WORKFLOWS, f), "utf8")),
);
expect(
missing,
"a workflow runs the unit suite without installing the headless " +
"Chromium the hero-geometry check measures with — that check refuses " +
"to skip under CI, so the job fails on a launch error instead",
).toEqual([]);
});
});
+10 -9
View File
@@ -34,6 +34,7 @@ import {
GLUCOSE_PANEL_WINDOW_DAYS,
type GlucoseClinicalMetrics,
} from "@/lib/analytics/glucose-metrics";
import { groupByGlucoseContext } from "@/lib/glucose";
import {
decisionForEvidence,
PATTERN_FAMILIES,
@@ -495,16 +496,16 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true, glucoseContext: true },
});
// #943 — grouped through the shared bucket resolver, so readings with no
// meal-time tag land in `UNSPECIFIED` instead of being dropped on the floor.
const glucoseByContext: Record<string, ReturnType<typeof summarize>> = {};
if (glucoseRows.length > 0) {
const contexts = ["FASTING", "POSTPRANDIAL", "RANDOM", "BEDTIME"] as const;
for (const ctx of contexts) {
const ctxRows = glucoseRows.filter((r) => r.glucoseContext === ctx);
if (ctxRows.length === 0) continue;
glucoseByContext[ctx] = summarize(
ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })),
);
}
for (const [bucket, ctxRows] of groupByGlucoseContext(
glucoseRows,
(r) => r.glucoseContext,
)) {
glucoseByContext[bucket] = summarize(
ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })),
);
}
// v1.17.0 — server-authoritative glucose clinical panel. The TIR / GMI /
+109 -97
View File
@@ -37,6 +37,7 @@ import {
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { shapeInventoryItemNotes } from "@/lib/crypto/note-cipher";
import { withIdempotency } from "@/lib/idempotency";
type RouteParams = { params: Promise<{ id: string }> };
@@ -90,102 +91,113 @@ export const GET = apiHandler(
},
);
/**
* Wrapped in `withIdempotency`: registering a container is replayed out of the
* client's offline outbox under the same `Idempotency-Key`, and a replay after
* a lost success response would otherwise register the same pen twice and
* double the stock the low-stock notification reads.
*/
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
// v1.37.0 — MANAGE. Recording the stock the low-stock notification reads;
// that notification rings the record's own phone.
const { user, actor } = await requireRecordAuth("manage", "medications");
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
// Per-caller POST rate-limit — 30/min is generous for normal pen
// registrations but cuts off the spam case.
//
// v1.37.0 — C1: the bucket keys on the ACTOR, so a manager burns their
// own allowance rather than the owner's and cannot collect a fresh one by
// switching records.
const rl = await checkRateLimit(
`medication-inventory:post:${actor.id}`,
POST_RATE_LIMIT,
POST_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many requests", 429, {
headers: rateLimitHeaders(rl),
});
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = createInventoryItemSchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const {
unitsTotal,
containerType,
printedExpiry,
purchasedAt,
manufacturer,
doseStrength,
notes,
} = parsed.data;
const created = await prisma.medicationInventoryItem.create({
// The wire field `unitsTotal` counts UNITS (tablets / ampoules /
// puffs) — v1.16.10 renamed the request field to match the
// response side.
data: buildCreateInventoryInput({
userId: user.id,
medicationId: id,
unitsTotal,
containerType: containerType ?? "OTHER",
printedExpiry: printedExpiry ?? null,
purchasedAt: purchasedAt ?? null,
manufacturer: manufacturer ?? null,
doseStrength: doseStrength ?? null,
notes: notes ?? null,
}),
});
await auditLog("medication.inventory.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
medicationId: id,
itemId: created.id,
unitsTotal,
containerType: created.containerType,
},
});
annotate({
action: {
name: "medication.inventory.create",
entity_type: "inventory_item",
entity_id: created.id,
},
meta: { medication_id: id },
});
// A registered container changes the dose-derived stock the
// medications-list payload carries (`stockUnitsRemaining` /
// `stockDosesRemaining`), which the card and table render. Hard-evict
// the per-user medications + compliance buckets so the supply shows on
// the very next read — a mark-stale would let the `cachedSwr` list
// serve the pre-write stock for the rest of the stale window.
invalidateUserMedications(user.id, { evict: true });
return apiSuccess(
serializeInventoryItem(shapeInventoryItemNotes(created)),
201,
);
},
withIdempotency<[NextRequest, RouteParams]>(postInventoryItem),
);
async function postInventoryItem(
request: NextRequest,
{ params }: RouteParams,
): Promise<Response> {
// v1.37.0 — MANAGE. Recording the stock the low-stock notification reads;
// that notification rings the record's own phone.
const { user, actor } = await requireRecordAuth("manage", "medications");
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
// Per-caller POST rate-limit — 30/min is generous for normal pen
// registrations but cuts off the spam case.
//
// v1.37.0 — C1: the bucket keys on the ACTOR, so a manager burns their
// own allowance rather than the owner's and cannot collect a fresh one by
// switching records.
const rl = await checkRateLimit(
`medication-inventory:post:${actor.id}`,
POST_RATE_LIMIT,
POST_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many requests", 429, {
headers: rateLimitHeaders(rl),
});
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = createInventoryItemSchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const {
unitsTotal,
containerType,
printedExpiry,
purchasedAt,
manufacturer,
doseStrength,
notes,
} = parsed.data;
const created = await prisma.medicationInventoryItem.create({
// The wire field `unitsTotal` counts UNITS (tablets / ampoules /
// puffs) — v1.16.10 renamed the request field to match the
// response side.
data: buildCreateInventoryInput({
userId: user.id,
medicationId: id,
unitsTotal,
containerType: containerType ?? "OTHER",
printedExpiry: printedExpiry ?? null,
purchasedAt: purchasedAt ?? null,
manufacturer: manufacturer ?? null,
doseStrength: doseStrength ?? null,
notes: notes ?? null,
}),
});
await auditLog("medication.inventory.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
medicationId: id,
itemId: created.id,
unitsTotal,
containerType: created.containerType,
},
});
annotate({
action: {
name: "medication.inventory.create",
entity_type: "inventory_item",
entity_id: created.id,
},
meta: { medication_id: id },
});
// A registered container changes the dose-derived stock the
// medications-list payload carries (`stockUnitsRemaining` /
// `stockDosesRemaining`), which the card and table render. Hard-evict
// the per-user medications + compliance buckets so the supply shows on
// the very next read — a mark-stale would let the `cachedSwr` list
// serve the pre-write stock for the rest of the stale window.
invalidateUserMedications(user.id, { evict: true });
return apiSuccess(
serializeInventoryItem(shapeInventoryItemNotes(created)),
201,
);
}
@@ -38,6 +38,7 @@ import {
import { categoryForEntry } from "@/lib/medications/side-effects/taxonomy";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { encryptNote, shapeSideEffectNotes } from "@/lib/crypto/note-cipher";
import { withIdempotency } from "@/lib/idempotency";
type RouteParams = { params: Promise<{ id: string }> };
@@ -96,93 +97,104 @@ export const GET = apiHandler(
},
);
/**
* Wrapped in `withIdempotency`: the client replays this write out of its
* offline outbox with the same `Idempotency-Key`, and a replay after a lost
* success response must not log the same symptom twice. Nothing else on this
* row is unique, so the wrapper is the only thing that can collapse a retry.
*/
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
// v1.36.x — a delegated write. `user` is the record the entry lands under;
// `actor` is whoever is typing, and the two differ only under a switch.
const { user, actor } = await requireRecordAuth("write", "medications");
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
// Per-caller POST rate-limit — 30/min comfortably absorbs a session
// of bulk back-fill (e.g. "log yesterday's symptoms") while cutting
// off automated abuse.
//
// v1.36.x — the bucket keys on the ACTOR, not on the resolved record, and
// this is the one condition on which delegating this verb was admitted. On
// the record key a delegate could exhaust the owner's allowance and lock
// them out of their own log, and could collect a fresh one by switching to
// another record. On the actor key they burn their own, once, wherever
// they are. `medications/compliance` set the same precedent on the read
// side. `actor.id` equals `user.id` for everyone who has not switched, so
// the bucket a self-writer lands in is byte-identical to before.
const rl = await checkRateLimit(
`medication-side-effect:post:${actor.id}`,
POST_RATE_LIMIT,
POST_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many requests", 429, {
headers: rateLimitHeaders(rl),
});
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = createSideEffectSchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const { entry, severity, occurredAt, notes } = parsed.data;
// v1.4.25 W21 Fix-N (code-M6) — category is derived server-side
// from the entry via the authoritative taxonomy mapping. The wire
// schema no longer accepts `category`; older clients that still
// send it now have it ignored by Zod's strict drop, and the row
// lands with the correct (entry-derived) category every time.
const category = categoryForEntry(entry);
const created = await prisma.medicationSideEffect.create({
data: {
userId: user.id,
medicationId: id,
category,
entry,
severity,
occurredAt: occurredAt ?? new Date(),
// Encrypt the free-text note at rest; the plaintext column stays null.
notesEncrypted: encryptNote(notes),
notes: null,
},
});
await auditLog("medication.sideEffect.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
medicationId: id,
sideEffectId: created.id,
entry,
severity,
},
});
annotate({
action: {
name: "medication.sideEffect.create",
entity_type: "medication_side_effect",
entity_id: created.id,
},
meta: { medication_id: id, entry, severity },
});
return apiSuccess(shapeSideEffectNotes(created), 201);
},
withIdempotency<[NextRequest, RouteParams]>(postSideEffect),
);
async function postSideEffect(
request: NextRequest,
{ params }: RouteParams,
): Promise<Response> {
// v1.36.x — a delegated write. `user` is the record the entry lands under;
// `actor` is whoever is typing, and the two differ only under a switch.
const { user, actor } = await requireRecordAuth("write", "medications");
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
// Per-caller POST rate-limit — 30/min comfortably absorbs a session
// of bulk back-fill (e.g. "log yesterday's symptoms") while cutting
// off automated abuse.
//
// v1.36.x — the bucket keys on the ACTOR, not on the resolved record, and
// this is the one condition on which delegating this verb was admitted. On
// the record key a delegate could exhaust the owner's allowance and lock
// them out of their own log, and could collect a fresh one by switching to
// another record. On the actor key they burn their own, once, wherever
// they are. `medications/compliance` set the same precedent on the read
// side. `actor.id` equals `user.id` for everyone who has not switched, so
// the bucket a self-writer lands in is byte-identical to before.
const rl = await checkRateLimit(
`medication-side-effect:post:${actor.id}`,
POST_RATE_LIMIT,
POST_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many requests", 429, {
headers: rateLimitHeaders(rl),
});
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = createSideEffectSchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const { entry, severity, occurredAt, notes } = parsed.data;
// v1.4.25 W21 Fix-N (code-M6) — category is derived server-side
// from the entry via the authoritative taxonomy mapping. The wire
// schema no longer accepts `category`; older clients that still
// send it now have it ignored by Zod's strict drop, and the row
// lands with the correct (entry-derived) category every time.
const category = categoryForEntry(entry);
const created = await prisma.medicationSideEffect.create({
data: {
userId: user.id,
medicationId: id,
category,
entry,
severity,
occurredAt: occurredAt ?? new Date(),
// Encrypt the free-text note at rest; the plaintext column stays null.
notesEncrypted: encryptNote(notes),
notes: null,
},
});
await auditLog("medication.sideEffect.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
medicationId: id,
sideEffectId: created.id,
entry,
severity,
},
});
annotate({
action: {
name: "medication.sideEffect.create",
entity_type: "medication_side_effect",
entity_id: created.id,
},
meta: { medication_id: id, entry, severity },
});
return apiSuccess(shapeSideEffectNotes(created), 201);
}
+12 -2
View File
@@ -23,6 +23,7 @@ import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { readMedicationsListCached } from "@/lib/medications/list-read";
import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto";
import { withIdempotency } from "@/lib/idempotency";
import { NextRequest } from "next/server";
// v1.32.25 — blast-radius cap on externally-mirrored medications per user.
@@ -103,7 +104,16 @@ async function respondWithExistingMirror(
});
}
export const POST = apiHandler(async (request: NextRequest) => {
/**
* The create is wrapped in `withIdempotency` so a client replaying its offline
* outbox after a lost success response replays the first answer instead of
* minting a second medication. The `(externalSource, externalId)` dedupe below
* only ever covered a MIRRORED create; a manually entered medication carries
* neither field and had nothing to collapse a retry onto.
*/
export const POST = apiHandler(withIdempotency<[NextRequest]>(postMedication));
async function postMedication(request: NextRequest): Promise<Response> {
// v1.36.x — a delegated write, and the asymmetric one: a delegate can ADD a
// medication with its whole nested schedule, and can never edit or delete
// it afterwards — `PUT /api/medications/[id]` and the DELETE beside it stay
@@ -424,4 +434,4 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
201,
);
});
}
+11 -22
View File
@@ -174,6 +174,7 @@ import {
resolveConfiguredTileCount,
resolveChartRowPlaceholderCount,
pickHrvSummary,
resolveGlucoseTiles,
} from "@/components/dashboard/dashboard-gates";
/**
@@ -725,23 +726,12 @@ export default function DashboardPageClient({
const glucoseWidgetVisible = isTileVisible("glucose");
const displayGlucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null);
const glucoseByContext = data?.glucoseByContext ?? {};
const glucoseContexts = [
"FASTING",
"POSTPRANDIAL",
"RANDOM",
"BEDTIME",
] as const;
const glucoseSummariesPresent = glucoseContexts.filter(
(ctx) => (glucoseByContext[ctx]?.count ?? 0) > 0,
);
const showGlucoseCards =
glucoseWidgetVisible && glucoseSummariesPresent.length > 0;
const glucoseLabelKey: Record<string, string> = {
FASTING: "targets.glucoseFasting",
POSTPRANDIAL: "targets.glucosePostprandial",
RANDOM: "targets.glucoseRandom",
BEDTIME: "targets.glucoseBedtime",
};
// #943 — eligibility comes from `resolveGlucoseTiles`, which walks the
// untagged bucket alongside the four named contexts. The gate used to be
// an inline filter over the named contexts only, so an account whose
// source never writes a meal-time tag saw nothing.
const glucoseTiles = resolveGlucoseTiles(glucoseByContext);
const showGlucoseCards = glucoseWidgetVisible && glucoseTiles.length > 0;
// v1.18.6 — band / target math is computed SERVER-side in the snapshot
// DTO (`targetBands`) so the client stops recomputing it from the
// profile (audit finding #3). When snapshot mode is on, read the
@@ -1634,16 +1624,15 @@ export default function DashboardPageClient({
}
if (showGlucoseCards) {
const glucoseOrder = widgetOrder("glucose");
glucoseSummariesPresent.forEach((ctx, idx) => {
const s = glucoseByContext[ctx];
glucoseTiles.forEach(({ bucket, labelKey, summary: s }, idx) => {
trendCards.push({
id: `glucose-${ctx}`,
id: `glucose-${bucket}`,
// sub-order so all glucose cards stay in a block and order stable
order: glucoseOrder + idx / 1000,
node: (
<TrendCard
key={`glucose-${ctx}`}
label={t(glucoseLabelKey[ctx])}
key={`glucose-${bucket}`}
label={t(labelKey)}
latest={
s.latest != null
? convertGlucose(s.latest, displayGlucoseUnit)
+5
View File
@@ -1945,6 +1945,11 @@ export function HealthChart({
) : null}
<div
// Stable hook for the browser suite. `.recharts-wrapper` is a
// library-internal class and it is what the a11y scan used to
// gate on; this slot is rendered by the data branch only, so it
// says the same thing without depending on Recharts' markup.
data-slot="chart-plot"
className="relative z-10 h-full touch-pan-y"
role="img"
aria-label={chartAriaLabel}
+4
View File
@@ -767,6 +767,10 @@ export function MoodChart({
? "h-[var(--chart-height,140px)]"
: "h-[var(--chart-height,200px)] md:h-[var(--chart-height-md,220px)]"
} touch-pan-y`}
// Same stable hook as the measurement chart: rendered by the data
// branch only, so the browser suite can gate on it instead of on
// Recharts' own class names.
data-slot="chart-plot"
role="img"
aria-label={moodAriaLabel}
>
@@ -0,0 +1,79 @@
/**
* #943 — the Blood Glucose tile must not depend on a meal-time tag.
*
* A reporter's meter syncs through Apple Health and never writes HealthKit's
* blood-glucose meal-time metadata, so all 358 of their readings carry an
* empty `glucose_context`. The tile strip fanned out over the four named
* contexts only, found none of them populated, and omitted the tile — with
* the glucose module on, the layout toggle on, and a reading from today.
*
* These cases pin the untagged bucket: it counts toward eligibility and
* carries the latest value and the trend, while a tagged account still gets
* its per-context breakdown.
*/
import { describe, expect, it } from "vitest";
import { resolveGlucoseTiles } from "../dashboard-gates";
import type { DataSummary } from "@/lib/analytics/trends";
function summary(overrides: Partial<DataSummary> = {}): DataSummary {
return {
count: 12,
latest: 118,
min: 92,
max: 149,
mean: 117,
median: 116,
avg7: 119,
avg30: 117,
slope7: null,
slope30: { slope: 0.4, direction: "up", confidence: 0.6 },
...overrides,
};
}
describe("resolveGlucoseTiles (#943)", () => {
it("is eligible when every reading is untagged", () => {
const tiles = resolveGlucoseTiles({
UNSPECIFIED: summary({ count: 358, latest: 104 }),
});
expect(tiles).toHaveLength(1);
expect(tiles[0].bucket).toBe("UNSPECIFIED");
expect(tiles[0].summary.latest).toBe(104);
expect(tiles[0].summary.slope30).not.toBeNull();
});
it("keeps the named breakdown and appends the untagged bucket last", () => {
const tiles = resolveGlucoseTiles({
FASTING: summary({ count: 5 }),
BEDTIME: summary({ count: 3 }),
UNSPECIFIED: summary({ count: 9 }),
});
expect(tiles.map((tile) => tile.bucket)).toEqual([
"FASTING",
"BEDTIME",
"UNSPECIFIED",
]);
});
it("stays ineligible when nothing is logged", () => {
expect(resolveGlucoseTiles(undefined)).toEqual([]);
expect(resolveGlucoseTiles({})).toEqual([]);
expect(resolveGlucoseTiles({ UNSPECIFIED: summary({ count: 0 }) })).toEqual(
[],
);
});
it("names every bucket it emits", () => {
const tiles = resolveGlucoseTiles({
RANDOM: summary(),
UNSPECIFIED: summary(),
});
for (const tile of tiles) {
expect(tile.labelKey).toMatch(/^targets\.glucose/);
}
});
});
@@ -7,6 +7,10 @@
* `../page` test imports stay valid.
*/
import type { DataSummary } from "@/lib/analytics/trends";
import {
GLUCOSE_CONTEXT_BUCKETS,
type GlucoseContextBucket,
} from "@/lib/glucose";
import type { DashboardLayout } from "@/lib/dashboard-layout";
/**
@@ -207,3 +211,46 @@ export function pickHrvSummary(
// no HRV at all look like a ring user.
return pick(primary ?? null, "HEART_RATE_VARIABILITY");
}
/**
* The i18n key naming each glucose bucket on the tile strip. The untagged
* bucket is named, not blank: a strip of cards where one carries no label
* reads as a rendering fault, and the user needs to know the readings behind
* it are the ones their meter never tagged.
*/
export const GLUCOSE_TILE_LABEL_KEY: Record<GlucoseContextBucket, string> = {
FASTING: "targets.glucoseFasting",
POSTPRANDIAL: "targets.glucosePostprandial",
RANDOM: "targets.glucoseRandom",
BEDTIME: "targets.glucoseBedtime",
UNSPECIFIED: "targets.glucoseUnspecified",
};
/** One resolved glucose strip tile. */
export interface GlucoseTile {
bucket: GlucoseContextBucket;
labelKey: string;
summary: DataSummary;
}
/**
* Which glucose tiles the strip paints, in the canonical bucket order. An
* empty result means the tile is not eligible and the whole glucose block is
* omitted.
*
* #943 — the untagged bucket is in the loop, not outside it. Gating on the
* four named contexts alone hid the tile from every account whose source
* writes no meal-time tag, with no warning anywhere: the module was on, the
* layout toggle was on, the readings were current, and the strip was empty.
*/
export function resolveGlucoseTiles(
byContext: Record<string, DataSummary> | undefined,
): GlucoseTile[] {
const tiles: GlucoseTile[] = [];
for (const bucket of GLUCOSE_CONTEXT_BUCKETS) {
const summary = byContext?.[bucket];
if (!summary || summary.count <= 0) continue;
tiles.push({ bucket, labelKey: GLUCOSE_TILE_LABEL_KEY[bucket], summary });
}
return tiles;
}
+32
View File
@@ -0,0 +1,32 @@
import type { ReactNode } from "react";
/**
* The error slot that belongs to one input.
*
* Every form that writes the profile shows a refused field the same
* way, or the same rejection reads differently depending on where the
* person happened to be standing. This is that one way: a destructive
* sentence directly under the control, carrying an id the control
* points `aria-describedby` at, so the reason is announced with the
* field rather than only painted next to it.
*
* `role="alert"` is deliberate. The sentence appears in response to a
* save the person just asked for, and a person who has already moved
* focus past the field would otherwise never learn the value did not
* land.
*/
export function FieldError({
id,
message,
}: {
/** Conventionally `<control id>-error`. */
id: string;
message?: ReactNode;
}) {
if (!message) return null;
return (
<p id={id} role="alert" className="text-destructive text-sm">
{message}
</p>
);
}
@@ -223,16 +223,23 @@ beforeAll(async () => {
browser = await chromium.launch();
} catch (error) {
const reason = `no Chromium build available (${String(error).slice(0, 200)})`;
// In CI the browser is installed by the quality job, so a launch failure
// is the job being wrong, not the machine being bare. Throwing from the
// hook fails every test in this suite: a geometry check that quietly
// skips is the same thing as no geometry check, and this repo shipped
// exactly that for months because the skip only ever reached a console
// line nobody reads.
// In CI the browser is installed by the job that runs the suite, so a
// launch failure is the job being wrong, not the machine being bare.
// Throwing from the hook fails every test in this suite: a geometry check
// that quietly skips is the same thing as no geometry check, and this repo
// shipped exactly that for months because the skip only ever reached a
// console line nobody reads.
//
// TWO jobs run `pnpm test`, and naming only one of them here sent a reader
// to a workflow that was already correct: the quality job in
// `security.yml` and the auto-merge job in `dependabot-auto-merge.yml`.
// The second had no install step for three days after this check started
// failing instead of skipping, and turned every dependency bump red.
if (process.env.CI) {
throw new Error(
`[hero geometry] ${reason}. This check is the gate in CI and it has no browser to measure with. ` +
`Restore the "pnpm exec playwright install --only-shell chromium" step in the quality job of .github/workflows/security.yml.`,
`Add the "pnpm exec playwright install --only-shell chromium" step to whichever job ran this suite — ` +
`the quality job of .github/workflows/security.yml, or the auto-merge job of .github/workflows/dependabot-auto-merge.yml.`,
);
}
skipReason = reason;
@@ -31,6 +31,8 @@ const CONTEXT_LABEL_KEYS: Record<string, string> = {
"insights.bloodGlucose.clinical.advanced.byContext.postprandial",
RANDOM: "insights.bloodGlucose.clinical.advanced.byContext.random",
BEDTIME: "insights.bloodGlucose.clinical.advanced.byContext.bedtime",
// #943 — readings whose source recorded no meal-time context.
UNSPECIFIED: "insights.bloodGlucose.clinical.advanced.byContext.unspecified",
};
export interface GlucoseAdvancedDisclosureProps {
@@ -119,6 +119,10 @@ const GLUCOSE_TARGET_TYPES = [
"BLOOD_GLUCOSE_POSTPRANDIAL",
"BLOOD_GLUCOSE_RANDOM",
"BLOOD_GLUCOSE_BEDTIME",
// #943 — readings whose source recorded no meal-time context. The route
// emits this card whenever such rows exist, so the panel must list it or
// an account with only untagged readings gets no reference band at all.
"BLOOD_GLUCOSE_UNSPECIFIED",
];
interface MetricTargetSummaryProps {
@@ -605,7 +605,14 @@ export function MeasurementForm({
</SelectTrigger>
<SelectContent>
{GLUCOSE_CONTEXTS.map((ctx) => (
<SelectItem key={ctx.value} value={ctx.value}>
<SelectItem
key={ctx.value}
value={ctx.value}
// The option carries its enum alongside the translated
// label, so an option can be chosen by which meal time it
// means rather than by the words the current locale prints.
data-glucose-context={ctx.value}
>
{t(ctx.labelKey)}
</SelectItem>
))}
@@ -343,6 +343,29 @@ export function MeasurementList({
},
[unitDisplay, glucoseUnit],
);
/**
* Machine-readable mirror of what the row renders — the same number and
* the same unit, before `Intl` turns them into locale text.
*
* The rendered cell is the only place a reading's value and unit appear
* together, and it appears there as "8.421 steps" or "8,421 steps"
* depending on who is looking. A browser test that has to assert a stored
* unit therefore either pins a locale or matches a loose number, and both
* describe the formatter rather than the reading. These attributes carry
* the reading itself, and the desktop table and the mobile list emit the
* same set, so an assertion holds at either viewport.
*/
const rowIdentityAttrs = useCallback(
(m: Measurement, isGrouped: boolean) => ({
"data-testid": "measurement-row",
"data-measurement-type": m.type,
"data-measurement-value": String(
isGrouped ? m.value : rowDisplay(m).value,
),
"data-measurement-unit": isGrouped ? m.unit : rowDisplay(m).unit,
}),
[rowDisplay],
);
// v1.28.42 (H3) — the desktop table and the mobile card list used to BOTH
// render (only CSS `display` hid one), so a dense cumulative (up to ~5000
// rows) or sleep page built every row subtree twice — double the DOM, double
@@ -1161,6 +1184,17 @@ export function MeasurementList({
<Fragment key={m.id}>
<TableRow
data-state={isSelected ? "selected" : undefined}
// A row is addressable by what it holds, not by
// the copy it paints: the type is an enum and the
// unit is a symbol, so a browser journey can pin
// "this glucose reading now reads in mmol/L"
// without matching translated text that also
// renders at a different size on the card list
// below.
{...rowIdentityAttrs(m, isGrouped)}
data-display-unit={
isGrouped ? m.unit : rowDisplay(m).unit
}
>
<TableCell className="pl-4">
{/* Grouped/synthetic rows aren't individually
@@ -1197,7 +1231,10 @@ export function MeasurementList({
</button>
</Badge>
</TableCell>
<TableCell className="font-semibold tabular-nums">
<TableCell
className="font-semibold tabular-nums"
data-slot="measurement-row-value"
>
{/* v1.4.39.3 — non-grouped rows render the
stored value with its native precision
(`fmt.number` honours up to 3 fraction
@@ -1358,6 +1395,11 @@ export function MeasurementList({
<ListRow
key={m.id}
data-state={isSelected ? "selected" : undefined}
// Same addressing contract as the desktop row above.
{...rowIdentityAttrs(m, isGrouped)}
data-display-unit={
isGrouped ? m.unit : rowDisplay(m).unit
}
className="bg-card border-border data-[state=selected]:border-primary/60 data-[state=selected]:bg-primary/5"
>
<div className="flex items-center justify-between">
@@ -1412,7 +1454,10 @@ export function MeasurementList({
{t(TYPE_LABEL_KEYS[m.type])}
</Badge>
)}
<span className="font-semibold tabular-nums">
<span
className="font-semibold tabular-nums"
data-slot="measurement-row-value"
>
{/* v1.4.39.3 — mirror the desktop table:
grouped daily aggregates stay integer,
non-grouped single readings render with
@@ -0,0 +1,175 @@
/**
* A rejection the server names has to reach the input it belongs to.
*
* `PUT /api/auth/profile` writes field by field and reports the fields
* it refused. The onboarding step read that report only far enough to
* flash a toast and then walked the person to the next screen, so an
* out-of-range height was gone by the time anyone could act on it and
* the wizard counted the account as set up regardless.
*
* This runs the whole strip — build the body, PUT it over a stubbed
* `fetch`, read the answer, render the real inputs with what came back
* — because the gap was in the join, not in either end. The English
* bundle is the shipped one, so a sentence that reads as a key or
* leaks validator prose fails here.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { getCachedMessages } from "@/lib/i18n/load-locale";
import { resolveKey } from "@/lib/i18n/resolve-key";
import {
EMPTY_HEIGHT_DRAFT,
resolveHeightUnitAdapter,
} from "@/lib/profile/height-unit-display";
import { BaselineFields } from "../baseline-fields";
import {
baselineFieldLabelKeys,
buildBaselineProfileBody,
describeBaselineSaveOutcome,
putBaselineProfile,
} from "../baseline-form-utils";
/** A `t` backed by the shipped bundle — no stand-in strings. */
function translator(locale: "en" | "de") {
const bundle = getCachedMessages(locale);
if (!bundle) throw new Error(`No cached bundle for ${locale}`);
return (key: string, params?: Record<string, string | number>) => {
const raw = resolveKey(bundle as Record<string, unknown>, key);
if (raw === undefined) return key;
return Object.entries(params ?? {}).reduce(
(acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)),
raw,
);
};
}
const FILLED_FORM = {
displayName: "Robin",
dateOfBirth: "1988-04-02",
gender: "OTHER",
};
function stubProfilePut(status: number, body: unknown) {
vi.stubGlobal("fetch", () =>
Promise.resolve(
new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
}),
),
);
}
/** Render the step's inputs exactly as the step renders them. */
function renderFields(errors: Record<string, string>) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<BaselineFields
value={{
displayName: FILLED_FORM.displayName,
height: { ...EMPTY_HEIGHT_DRAFT, cm: "940" },
dateOfBirth: FILLED_FORM.dateOfBirth,
gender: FILLED_FORM.gender,
}}
onChange={vi.fn()}
heightAdapter={resolveHeightUnitAdapter("metric")}
errors={errors}
/>
</I18nProvider>,
);
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("a refused baseline field reaches its own input", () => {
it("paints the reason in the height field's slot and holds the step", async () => {
stubProfilePut(200, {
data: {
id: "u1",
displayName: "Robin",
rejectedFields: [{ path: "heightCm", code: "too_big" }],
},
error: null,
});
const en = translator("en");
const outcome = describeBaselineSaveOutcome(
await putBaselineProfile(buildBaselineProfileBody(FILLED_FORM, 940)),
en,
baselineFieldLabelKeys(false),
);
// A field was refused: the wizard stays put and does not stamp the
// account as finished behind the person's back.
expect(outcome.advance).toBe(false);
expect(outcome.fieldErrors).toEqual({
heightCm: en("settings.profileRejection.tooBig"),
});
// What the outcome produced is what the input renders — no second
// mapping in between for a rename to fall through.
const html = renderFields(outcome.fieldErrors);
expect(html).toContain(en("settings.profileRejection.tooBig"));
// Neither a bare key nor the validator's own vocabulary.
expect(html).not.toContain("settings.profileRejection");
expect(html).not.toContain("too_big");
// Announced with the input, not merely painted beside it.
expect(html).toContain('id="ob-baseline-height-error"');
expect(html).toContain('aria-describedby="ob-baseline-height-error"');
expect(html).toContain('aria-invalid="true"');
});
it("leaves every accepted field's slot empty", async () => {
stubProfilePut(200, { data: { id: "u1" }, error: null });
const outcome = describeBaselineSaveOutcome(
await putBaselineProfile(buildBaselineProfileBody(FILLED_FORM, 178)),
translator("en"),
baselineFieldLabelKeys(false),
);
expect(outcome.advance).toBe(true);
expect(outcome.fieldErrors).toEqual({});
// (`aria-invalid:` also appears inside the primitives' Tailwind
// class strings, so the assertion is on the rendered attribute.)
const html = renderFields({});
expect(html).not.toContain('aria-invalid="true"');
expect(html).not.toContain('role="alert"');
});
it("names every refused field, not only the first", async () => {
stubProfilePut(422, {
data: null,
error: 'Nothing was saved. Fix the "dateOfBirth" field and try again.',
meta: { errorCode: "profile.update.nothingSaved" },
details: {
issues: [
{ path: "dateOfBirth", code: "invalid_value" },
{ path: "gender", code: "invalid_value" },
],
},
});
const outcome = describeBaselineSaveOutcome(
await putBaselineProfile(buildBaselineProfileBody(FILLED_FORM, null)),
translator("de"),
baselineFieldLabelKeys(false),
);
expect(outcome.advance).toBe(false);
expect(Object.keys(outcome.fieldErrors).sort()).toEqual([
"dateOfBirth",
"gender",
]);
// The person's language, not the validator's.
const de = translator("de");
expect(outcome.fieldErrors.gender).toBe(
de("settings.profileRejection.invalidValue"),
);
expect(outcome.fieldErrors.gender).not.toContain("invalid_value");
});
});
@@ -60,7 +60,7 @@ afterEach(() => {
});
describe("onboarding baseline profile save", () => {
it("names the rejected field and still lets the accepted ones stand", async () => {
it("names the rejected field, keeps the accepted ones, and holds the step", async () => {
const calls = stubProfilePut(200, {
data: {
id: "u1",
@@ -88,14 +88,21 @@ describe("onboarding baseline profile save", () => {
gender: "OTHER",
});
// The accepted fields are saved and the wizard moves on...
expect(outcome.advance).toBe(true);
// ...but the declined one is said out loud, by the name this screen
// put next to the input.
// The accepted fields are saved, and the declined one is said out
// loud by the name this screen put next to the input.
expect(outcome.notice).toEqual({
tone: "warning",
message: "Profile saved, but Height (cm) could not be updated.",
});
// This assertion was `true` until the rejection reached the input
// it belongs to. Advancing here posted `step: 4`, stamped the
// account as set up and replaced the only screen that could show
// the refused value — a toast on the way out was the whole notice
// the person got. The step now waits for a correction.
expect(outcome.advance).toBe(false);
expect(outcome.fieldErrors.heightCm).toBe(
"That value is above the range this field accepts.",
);
});
it("says nothing when every field was accepted", async () => {
@@ -107,7 +114,7 @@ describe("onboarding baseline profile save", () => {
baselineFieldLabelKeys(false),
);
expect(outcome).toEqual({ advance: true, notice: null });
expect(outcome).toEqual({ advance: true, notice: null, fieldErrors: {} });
});
it("names the blocking field and holds the step when nothing was saved", async () => {
@@ -0,0 +1,177 @@
"use client";
import { Input } from "@/components/ui/input";
import { DateField } from "@/components/ui/date-field";
import { FieldGroup } from "@/components/ui/field-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { HeightFieldControl } from "@/components/profile/height-field-control";
import { useTranslations } from "@/lib/i18n/context";
import type {
HeightDraft,
HeightUnitAdapter,
} from "@/lib/profile/height-unit-display";
/**
* The four profile inputs of the onboarding baseline step.
*
* Split out of `baseline-form.tsx` so the markup can be rendered on its
* own against a server answer. The step's whole defect lived between
* the response and these inputs — the server named a field it refused
* and nothing on screen changed — so the seam a test needs is exactly
* here: field values in, field rejections in, markup out.
*
* Each field id doubles as the id of its error paragraph
* (`<id>-error`), which is what the control points `aria-describedby`
* at, so a rejection is announced with the input rather than only
* painted next to it.
*/
export interface BaselineFieldValues {
displayName: string;
height: HeightDraft;
dateOfBirth: string;
gender: string;
}
/**
* Per-field sentences keyed by the SCHEMA field name the server uses
* (`heightCm`, not `height`) — the key the rejection arrives under, so
* nothing has to be renamed between the wire and the slot.
*/
export type BaselineFieldErrors = Partial<
Record<"displayName" | "heightCm" | "dateOfBirth" | "gender", string>
>;
const IDS = {
displayName: "ob-baseline-display-name",
heightCm: "ob-baseline-height",
dateOfBirth: "ob-baseline-dob",
gender: "ob-baseline-gender",
} as const;
export function BaselineFields({
value,
onChange,
heightAdapter,
errors,
}: {
value: BaselineFieldValues;
onChange: <K extends keyof BaselineFieldValues>(
key: K,
next: BaselineFieldValues[K],
) => void;
heightAdapter: HeightUnitAdapter;
errors: BaselineFieldErrors;
}) {
const { t } = useTranslations();
return (
<fieldset className="bg-card border-border space-y-4 rounded-xl border p-4 md:p-6">
<legend className="sr-only">{t("onboarding.baseline.title")}</legend>
<FieldGroup
htmlFor={IDS.displayName}
label={t("onboarding.baseline.displayNameLabel")}
hint={t("onboarding.baseline.displayNameHint")}
error={errors.displayName}
>
<Input
id={IDS.displayName}
value={value.displayName}
onChange={(e) => onChange("displayName", e.target.value)}
autoComplete="nickname"
maxLength={50}
placeholder={t("onboarding.baseline.displayNamePlaceholder")}
aria-invalid={errors.displayName ? true : undefined}
aria-describedby={
errors.displayName ? `${IDS.displayName}-error` : undefined
}
/>
</FieldGroup>
<div className="grid gap-4 sm:grid-cols-2">
<FieldGroup
htmlFor={IDS.heightCm}
label={
heightAdapter.usesFeetInches
? t("onboarding.baseline.heightLabelFtIn")
: t("onboarding.baseline.heightLabel")
}
error={errors.heightCm}
>
<HeightFieldControl
idPrefix={IDS.heightCm}
adapter={heightAdapter}
value={value.height}
onChange={(next) => onChange("height", next)}
autoComplete="off"
invalid={Boolean(errors.heightCm)}
describedBy={errors.heightCm ? `${IDS.heightCm}-error` : undefined}
/>
</FieldGroup>
<FieldGroup
htmlFor={IDS.gender}
label={t("onboarding.baseline.genderLabel")}
error={errors.gender}
>
<Select
// The design system's Radix Select uses an empty-string
// sentinel to mean "no selection"; map back and forth so
// the form state ("") and the Select's value (undefined-
// adjacent) stay aligned. v1.4.25 W21 Fix-N (design-M1).
value={value.gender === "" ? undefined : value.gender}
onValueChange={(next) => onChange("gender", next)}
>
<SelectTrigger
id={IDS.gender}
className="w-full"
data-slot="onboarding-baseline-gender"
aria-invalid={errors.gender ? true : undefined}
aria-describedby={
errors.gender ? `${IDS.gender}-error` : undefined
}
>
<SelectValue placeholder={t("onboarding.baseline.genderNone")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="MALE">
{t("onboarding.baseline.genderMale")}
</SelectItem>
<SelectItem value="FEMALE">
{t("onboarding.baseline.genderFemale")}
</SelectItem>
<SelectItem value="OTHER">
{t("onboarding.baseline.genderOther")}
</SelectItem>
</SelectContent>
</Select>
</FieldGroup>
</div>
<FieldGroup
htmlFor={IDS.dateOfBirth}
label={t("onboarding.baseline.dateOfBirthLabel")}
hint={t("onboarding.baseline.dateOfBirthHint")}
error={errors.dateOfBirth}
>
<DateField
id={IDS.dateOfBirth}
value={value.dateOfBirth}
onChange={(next) => onChange("dateOfBirth", next)}
max={new Date().toISOString().slice(0, 10)}
autoComplete="bday"
aria-invalid={errors.dateOfBirth ? true : undefined}
aria-describedby={
errors.dateOfBirth ? `${IDS.dateOfBirth}-error` : undefined
}
/>
</FieldGroup>
</fieldset>
);
}
@@ -1,6 +1,7 @@
import { apiFetchRaw } from "@/lib/api/api-fetch";
import {
describeRejectedProfileField,
describeRejectedProfileFields,
type RejectedProfileField,
} from "@/lib/profile/rejected-fields";
import { toProfileSex } from "@/lib/profile/sex";
@@ -15,14 +16,19 @@ import { toProfileSex } from "@/lib/profile/sex";
* screen with nothing said, and the person walked away believing it was
* stored.
*
* The settings account form already answers this question (see
* `handleSaveProfile` in `src/components/settings/account-section`):
* name the field in the person's own words, keep validator prose off
* the screen, and only claim a clean save when there is one. The two
* screens share the field-naming helper and the two sentences, so the
* same rejection reads the same wherever it happens; only the labels
* differ, because each screen calls the fields what its own inputs
* call them.
* The settings account form answers the same question the same way
* (see `handleSaveProfile` in `src/components/settings/account-section`):
* name the field in the person's own words, put the reason in that
* field's own slot, keep validator prose off the screen, and only claim
* a clean save when there is one. The two screens share the
* field-naming helper and the sentences, so the same rejection reads
* the same wherever it happens; only the labels differ, because each
* screen calls the fields what its own inputs call them.
*
* A refused field also stops the wizard. Walking on would stamp
* `onboardingCompletedAt` over a step the person has not finished, and
* the only screen that could have shown them the refused value is the
* one they just left.
*/
/** The parts of the profile response this step reads. */
@@ -41,6 +47,12 @@ export interface BaselineSaveOutcome {
advance: boolean;
/** A ready-to-show sentence, already localized. Null on a clean save. */
notice: { tone: "warning" | "error"; message: string } | null;
/**
* One localized reason per refused field, keyed by the schema field
* name so the step can drop each into the slot under its own input.
* Empty on a clean save and on a failure that named no field.
*/
fieldErrors: Record<string, string>;
}
/**
@@ -125,22 +137,25 @@ export function describeBaselineSaveOutcome(
if (res.ok) {
const rejected = res.body?.data?.rejectedFields;
const field = describeRejectedProfileField(rejected, t, labelKeys);
if (!field) return { advance: true, notice: null };
if (!field) return { advance: true, notice: null, fieldErrors: {} };
// Some fields landed, at least one did not. The wizard stops here:
// the accepted values are stored and stay in the inputs, the
// refused ones say why under their own control, and the person can
// correct and press the same button again. Moving on would mark
// the account set up over a value nobody ever saw refused.
return {
advance: true,
advance: false,
notice: {
tone: "warning",
message: t("settings.profilePartiallySaved", { field }),
},
fieldErrors: describeRejectedProfileFields(rejected, t),
};
}
const errorCode = res.body?.meta?.errorCode;
const blockingField = describeRejectedProfileField(
res.body?.details?.issues,
t,
labelKeys,
);
const issues = res.body?.details?.issues;
const blockingField = describeRejectedProfileField(issues, t, labelKeys);
if (errorCode === "profile.update.nothingSaved" && blockingField) {
return {
@@ -149,12 +164,14 @@ export function describeBaselineSaveOutcome(
tone: "error",
message: t("settings.profileNothingSaved", { field: blockingField }),
},
fieldErrors: describeRejectedProfileFields(issues, t),
};
}
return {
advance: false,
notice: { tone: "error", message: localizedCode(errorCode, t) },
fieldErrors: {},
};
}
+31 -98
View File
@@ -8,17 +8,10 @@ import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { DateField } from "@/components/ui/date-field";
import { FieldGroup } from "@/components/ui/field-group";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { HeightFieldControl } from "@/components/profile/height-field-control";
BaselineFields,
type BaselineFieldErrors,
} from "@/components/onboarding/baseline-fields";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import {
EMPTY_HEIGHT_DRAFT,
@@ -100,6 +93,10 @@ export function BaselineForm() {
const [form, setForm] = useState<BaselineFormState>(EMPTY_FORM);
const [saving, setSaving] = useState(false);
// Refusals from the last save, one sentence per field. Cleared on
// every attempt so a slot never keeps a reason the server no longer
// has, and cleared per field as it is edited.
const [fieldErrors, setFieldErrors] = useState<BaselineFieldErrors>({});
// v1.17.1 — optional anamnesis (conditions + allergies). Persisted
// through the existing encrypted self-context path
@@ -137,11 +134,23 @@ export function BaselineForm() {
value: BaselineFormState[K],
) {
setForm((prev) => ({ ...prev, [key]: value }));
// The person is answering the objection; the objection goes away
// while they type rather than sitting under a value they already
// corrected. `height` is the draft's name here and `heightCm` the
// schema's — the same field, so editing either box clears it.
const slot = key === "height" ? "heightCm" : key;
setFieldErrors((prev) => {
if (!(slot in prev)) return prev;
const next = { ...prev };
delete next[slot as keyof BaselineFieldErrors];
return next;
});
}
async function advance(opts: { saveProfile: boolean }) {
if (saving) return;
setSaving(true);
setFieldErrors({});
try {
if (opts.saveProfile) {
const profileBody = buildBaselineProfileBody(
@@ -160,9 +169,12 @@ export function BaselineForm() {
show(outcome.notice.message);
}
if (!outcome.advance) {
// Nothing was written. Staying on the step is the point —
// the person has a named field to fix and the values they
// typed are still in front of them.
// A field was refused. Staying on the step is the point —
// each refused input now says why under itself, the values
// the person typed are still in front of them, and the
// account is not stamped as set up over a value that never
// landed.
setFieldErrors(outcome.fieldErrors);
setSaving(false);
return;
}
@@ -201,91 +213,12 @@ export function BaselineForm() {
</p>
</header>
<fieldset className="bg-card border-border space-y-4 rounded-xl border p-4 md:p-6">
<legend className="sr-only">{t("onboarding.baseline.title")}</legend>
<FieldGroup
htmlFor="ob-baseline-display-name"
label={t("onboarding.baseline.displayNameLabel")}
hint={t("onboarding.baseline.displayNameHint")}
>
<Input
id="ob-baseline-display-name"
value={form.displayName}
onChange={(e) => patch("displayName", e.target.value)}
autoComplete="nickname"
maxLength={50}
placeholder={t("onboarding.baseline.displayNamePlaceholder")}
/>
</FieldGroup>
<div className="grid gap-4 sm:grid-cols-2">
<FieldGroup
htmlFor="ob-baseline-height"
label={
heightAdapter.usesFeetInches
? t("onboarding.baseline.heightLabelFtIn")
: t("onboarding.baseline.heightLabel")
}
>
<HeightFieldControl
idPrefix="ob-baseline-height"
adapter={heightAdapter}
value={form.height}
onChange={(next) => patch("height", next)}
autoComplete="off"
/>
</FieldGroup>
<FieldGroup
htmlFor="ob-baseline-gender"
label={t("onboarding.baseline.genderLabel")}
>
<Select
// The design system's Radix Select uses an empty-string
// sentinel to mean "no selection"; map back and forth so
// the form state ("") and the Select's value (undefined-
// adjacent) stay aligned. v1.4.25 W21 Fix-N (design-M1).
value={form.gender === "" ? undefined : form.gender}
onValueChange={(next) => patch("gender", next)}
>
<SelectTrigger
id="ob-baseline-gender"
className="w-full"
data-slot="onboarding-baseline-gender"
>
<SelectValue
placeholder={t("onboarding.baseline.genderNone")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="MALE">
{t("onboarding.baseline.genderMale")}
</SelectItem>
<SelectItem value="FEMALE">
{t("onboarding.baseline.genderFemale")}
</SelectItem>
<SelectItem value="OTHER">
{t("onboarding.baseline.genderOther")}
</SelectItem>
</SelectContent>
</Select>
</FieldGroup>
</div>
<FieldGroup
htmlFor="ob-baseline-dob"
label={t("onboarding.baseline.dateOfBirthLabel")}
hint={t("onboarding.baseline.dateOfBirthHint")}
>
<DateField
id="ob-baseline-dob"
value={form.dateOfBirth}
onChange={(value) => patch("dateOfBirth", value)}
max={new Date().toISOString().slice(0, 10)}
autoComplete="bday"
/>
</FieldGroup>
</fieldset>
<BaselineFields
value={form}
onChange={patch}
heightAdapter={heightAdapter}
errors={fieldErrors}
/>
<AnamnesisCard
value={anamnesis}
@@ -31,6 +31,10 @@ interface HeightFieldControlProps {
onChange: (next: HeightDraft) => void;
autoComplete?: string;
enterKeyHint?: "next";
/** Marks every box of the control as refused. */
invalid?: boolean;
/** Id of the error paragraph the control's boxes point at. */
describedBy?: string;
}
export function HeightFieldControl({
@@ -40,6 +44,8 @@ export function HeightFieldControl({
onChange,
autoComplete,
enterKeyHint,
invalid,
describedBy,
}: HeightFieldControlProps) {
const { t } = useTranslations();
const { bounds } = adapter;
@@ -58,6 +64,8 @@ export function HeightFieldControl({
max={bounds.cm.max}
step={0.1}
autoComplete={autoComplete}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
/>
);
}
@@ -77,6 +85,8 @@ export function HeightFieldControl({
max={bounds.feet.max}
step={1}
autoComplete={autoComplete}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
aria-label={t("common.heightFeet")}
/>
<span
@@ -99,6 +109,8 @@ export function HeightFieldControl({
max={bounds.inches.max}
step={1}
autoComplete={autoComplete}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
aria-label={t("common.heightInches")}
/>
<span
@@ -0,0 +1,83 @@
/**
* A refusal the person has already answered must not keep standing.
*
* The settings profile form paints one sentence per field the last save
* declined. It cleared them only on the next submit, so a corrected
* value sat under the reason it was refused until the person pressed
* save again — the onboarding baseline step drops the sentence while
* they type (`patch` in `baseline-form.tsx`) and this form did not.
*
* Two things have to hold, and both are checked here because either one
* alone passes on the defect: the state rule (the edited field's
* sentence goes, its siblings stay), and the wiring (every slot the form
* renders actually names itself when its input changes). A helper that
* nothing calls is exactly the shape this guard exists to catch.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { PROFILE_FIELD_LABEL_KEYS } from "@/lib/profile/rejected-fields";
import { clearRejectedField } from "../account-section-utils";
const SOURCE = readFileSync(resolve(__dirname, "../index.tsx"), "utf8");
/** The slots the form renders a refusal sentence into. */
function renderedSlots(): string[] {
return [
...new Set(
[...SOURCE.matchAll(/message=\{fieldErrors\.(\w+)\}/g)].map((m) => m[1]),
),
].sort();
}
/** The slots an input clears as it is edited. */
function clearedSlots(): string[] {
return [
...new Set(
[...SOURCE.matchAll(/clearFieldError\("(\w+)"\)/g)].map((m) => m[1]),
),
].sort();
}
describe("a rejected profile field clears as it is edited", () => {
it("drops the edited field's sentence and keeps the others", () => {
const refused = {
heightCm: "That value is above the range this field accepts.",
gender: "That value is not one this field accepts.",
};
const afterHeightEdit = clearRejectedField(refused, "heightCm");
expect(afterHeightEdit.heightCm).toBeUndefined();
// The gender value was not touched, so its refusal still answers
// the value that is still in the box.
expect(afterHeightEdit.gender).toBe(refused.gender);
// Editing the other field clears only its own slot in turn.
const afterBothEdits = clearRejectedField(afterHeightEdit, "gender");
expect(afterBothEdits).toEqual({});
// The original map is never mutated — it is React state.
expect(refused.heightCm).toBeDefined();
});
it("leaves a map with nothing in that slot untouched", () => {
const refused = { gender: "That value is not one this field accepts." };
// Same reference: typing in a field that was never refused must not
// hand React a new object every keystroke.
expect(clearRejectedField(refused, "email")).toBe(refused);
});
it("clears every slot the form can paint", () => {
// A guard that matches nothing is green for the wrong reason.
expect(renderedSlots().length).toBeGreaterThan(0);
expect(clearedSlots()).toEqual(renderedSlots());
});
it("paints a slot for every field the server can refuse", () => {
// The label map is the set of paths `applyProfileUpdate` names back
// to this screen; a path with no slot has nowhere to land.
expect(renderedSlots()).toEqual(
Object.keys(PROFILE_FIELD_LABEL_KEYS).sort(),
);
});
});
@@ -67,3 +67,25 @@ export function statusText(
* uses the default label map, which points at the labels these inputs
* already carry.
*/
/**
* Drop one field's refusal as the person edits that field.
*
* A refusal answers a value. Once the value changes it answers nothing,
* so it goes instead of sitting under an input that has already been
* corrected — the same rule the onboarding baseline step applies in its
* `patch` helper. Only the edited slot is dropped: the other fields the
* same save refused were not touched, so their sentences still stand.
*
* The map is returned unchanged when the slot holds nothing, so typing
* in a field that was never refused does not re-render the form.
*/
export function clearRejectedField(
errors: Record<string, string>,
slot: string,
): Record<string, string> {
if (!(slot in errors)) return errors;
const next = { ...errors };
delete next[slot];
return next;
}
@@ -37,9 +37,12 @@ import { detectBrowserTimezone } from "@/lib/tz/format";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import {
describeRejectedProfileField,
describeRejectedProfileFields,
type RejectedProfileField,
} from "@/lib/profile/rejected-fields";
import { FieldError } from "@/components/forms/field-error";
import {
clearRejectedField,
resolveInitialTimezone,
statusText,
type StatusMessage,
@@ -90,6 +93,20 @@ export function AccountSection() {
const [saveMsgType, setSaveMsgType] = useState<
"success" | "warning" | "error" | null
>(null);
// One sentence per field the last save refused, keyed by the schema
// name the server sent. The banner above the button says a save was
// partial; these say which value was not stored and why, under the
// input holding it. Cleared at the start of every save, and per field
// as that field is edited.
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
// The person is answering the objection; the objection goes away
// while they type rather than sitting under a value they already
// corrected. The slot is the schema name the server sent, which is
// what the sentence under the input is keyed by — the height boxes
// hold a `height` draft but name the `heightCm` slot.
function clearFieldError(slot: string) {
setFieldErrors((prev) => clearRejectedField(prev, slot));
}
// v1.23 — passkey + second-factor management moved to the dedicated
// /settings/security hub so "how I secure my account" reads as one place.
@@ -172,6 +189,7 @@ export function AccountSection() {
setSaving(true);
setSaveMsg(null);
setSaveMsgType(null);
setFieldErrors({});
const savedProfile = {
email: email.trim(),
height,
@@ -234,6 +252,7 @@ export function AccountSection() {
params: { field: describeRejectedProfileField(rejected, t) ?? "" },
});
setSaveMsgType("warning");
setFieldErrors(describeRejectedProfileFields(rejected, t));
await refetch();
setSeededUserId(null);
} else {
@@ -272,6 +291,7 @@ export function AccountSection() {
} | null;
const errorCode = json?.meta?.errorCode;
const field = describeRejectedProfileField(json?.details?.issues, t);
setFieldErrors(describeRejectedProfileFields(json?.details?.issues, t));
if (errorCode === "profile.update.nothingSaved" && field) {
setSaveMsg({ key: "settings.profileNothingSaved", params: { field } });
} else if (errorCode) {
@@ -336,12 +356,18 @@ export function AccountSection() {
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onChange={(e) => {
setEmail(e.target.value);
clearFieldError("email");
}}
placeholder={t("auth.emailPlaceholder")}
maxLength={320}
autoComplete="email"
enterKeyHint="next"
aria-invalid={fieldErrors.email ? true : undefined}
aria-describedby={fieldErrors.email ? "email-error" : undefined}
/>
<FieldError id="email-error" message={fieldErrors.email} />
</div>
</div>
@@ -351,7 +377,14 @@ export function AccountSection() {
<NativeSelect
id="gender"
value={gender}
onChange={(e) => setGender(e.target.value)}
onChange={(e) => {
setGender(e.target.value);
clearFieldError("gender");
}}
aria-invalid={fieldErrors.gender ? true : undefined}
aria-describedby={
fieldErrors.gender ? "gender-error" : undefined
}
>
<option value="">{t("settings.genderNone")}</option>
<option value="MALE">{t("settings.genderMale")}</option>
@@ -361,6 +394,7 @@ export function AccountSection() {
<p className="text-muted-foreground text-xs">
{t("settings.genderHint")}
</p>
<FieldError id="gender-error" message={fieldErrors.gender} />
</div>
<div className="space-y-2">
<Label htmlFor="height">
@@ -372,9 +406,15 @@ export function AccountSection() {
idPrefix="height"
adapter={heightAdapter}
value={height}
onChange={setHeight}
onChange={(next) => {
setHeight(next);
clearFieldError("heightCm");
}}
enterKeyHint="next"
invalid={Boolean(fieldErrors.heightCm)}
describedBy={fieldErrors.heightCm ? "height-error" : undefined}
/>
<FieldError id="height-error" message={fieldErrors.heightCm} />
</div>
</div>
@@ -390,12 +430,20 @@ export function AccountSection() {
<DateField
id="dob"
value={dateOfBirth}
onChange={setDateOfBirth}
onChange={(next) => {
setDateOfBirth(next);
clearFieldError("dateOfBirth");
}}
max={new Date().toISOString().slice(0, 10)}
aria-invalid={fieldErrors.dateOfBirth ? true : undefined}
aria-describedby={
fieldErrors.dateOfBirth ? "dob-error" : undefined
}
/>
<p className="text-muted-foreground text-xs">
{t("settings.dateOfBirthHint")}
</p>
<FieldError id="dob-error" message={fieldErrors.dateOfBirth} />
</div>
<div className="space-y-2">
<Label htmlFor="language-select">{t("settings.language")}</Label>
@@ -451,11 +499,19 @@ export function AccountSection() {
<Input
id="full-name"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
onChange={(e) => {
setFullName(e.target.value);
clearFieldError("fullName");
}}
placeholder={t("settings.identity.fullNamePlaceholder")}
maxLength={120}
autoComplete="name"
aria-invalid={fieldErrors.fullName ? true : undefined}
aria-describedby={
fieldErrors.fullName ? "full-name-error" : undefined
}
/>
<FieldError id="full-name-error" message={fieldErrors.fullName} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
@@ -465,9 +521,20 @@ export function AccountSection() {
<Input
id="insurer"
value={insurerName}
onChange={(e) => setInsurerName(e.target.value)}
onChange={(e) => {
setInsurerName(e.target.value);
clearFieldError("insurerName");
}}
placeholder={t("settings.identity.insurerPlaceholder")}
maxLength={120}
aria-invalid={fieldErrors.insurerName ? true : undefined}
aria-describedby={
fieldErrors.insurerName ? "insurer-error" : undefined
}
/>
<FieldError
id="insurer-error"
message={fieldErrors.insurerName}
/>
</div>
<div className="space-y-2">
@@ -477,17 +544,28 @@ export function AccountSection() {
<Input
id="insurance-number"
value={insuranceNumber}
onChange={(e) =>
setInsuranceNumber(e.target.value.toUpperCase())
}
onChange={(e) => {
setInsuranceNumber(e.target.value.toUpperCase());
clearFieldError("insuranceNumber");
}}
placeholder="A123456780"
maxLength={10}
autoCapitalize="characters"
spellCheck={false}
aria-invalid={fieldErrors.insuranceNumber ? true : undefined}
aria-describedby={
fieldErrors.insuranceNumber
? "insurance-number-error"
: undefined
}
/>
<p className="text-muted-foreground text-xs">
{t("settings.identity.insuranceNumberHint")}
</p>
<FieldError
id="insurance-number-error"
message={fieldErrors.insuranceNumber}
/>
</div>
</div>
</div>
@@ -499,6 +499,7 @@ export function AppleHealthImportCard() {
)}
{(isFailed || uploadError) && (
<p
data-testid="import-apple-health-error"
role="alert"
className="text-destructive flex items-start gap-2 text-sm"
>
@@ -69,6 +69,10 @@ const TARGET_TYPE_TO_METRIC: Record<string, ThresholdMetric | null> = {
// BLOOD_PRESSURE handled specially with both sys + dia.
// BMI / MOOD_SCORE / MOOD_STABILITY / MEDICATION_COMPLIANCE /
// BLOOD_PRESSURE_IN_TARGET — derived, no editable threshold.
// BLOOD_GLUCOSE_UNSPECIFIED (#943) — deliberately absent. The untagged
// bucket is judged against the RANDOM band; giving it its own editor row
// would offer two controls that write one threshold, so the cog renders
// disabled the same way it does for a derived card.
};
interface CurrentRange {
+7
View File
@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { FieldError } from "@/components/forms/field-error";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
@@ -10,6 +11,9 @@ import { cn } from "@/lib/utils";
* is a no-op on rendered output.
*
* - `hint` renders below the control as muted fine print.
* - `error` renders below the hint as the field's own error slot, with
* the id `<htmlFor>-error` so the control can point
* `aria-describedby` at it.
* - `labelAccessory` sits right-aligned in the label row (e.g. a char counter);
* when present the label row becomes a `justify-between` flex row.
*/
@@ -17,6 +21,7 @@ export function FieldGroup({
htmlFor,
label,
hint,
error,
labelAccessory,
className,
labelClassName,
@@ -25,6 +30,7 @@ export function FieldGroup({
htmlFor: string;
label: ReactNode;
hint?: ReactNode;
error?: ReactNode;
labelAccessory?: ReactNode;
className?: string;
labelClassName?: string;
@@ -46,6 +52,7 @@ export function FieldGroup({
)}
{children}
{hint ? <p className="text-muted-foreground text-xs">{hint}</p> : null}
<FieldError id={`${htmlFor}-error`} message={error} />
</div>
);
}
+49
View File
@@ -6,6 +6,9 @@ import {
toCanonicalMgdl,
resolveGlucoseUnit,
thresholdMetricForContext,
glucoseContextBucket,
groupByGlucoseContext,
GLUCOSE_CONTEXT_UNSPECIFIED,
} from "../glucose";
describe("glucose conversion", () => {
@@ -108,3 +111,49 @@ describe("glucose conversion", () => {
expect(toCanonicalMgdl(mgdlToMmol(70), "mmol/L")).toBe(70);
});
});
describe("glucose context buckets (#943)", () => {
it("files NULL, blank and unknown values under the untagged bucket", () => {
expect(glucoseContextBucket(null)).toBe(GLUCOSE_CONTEXT_UNSPECIFIED);
expect(glucoseContextBucket(undefined)).toBe(GLUCOSE_CONTEXT_UNSPECIFIED);
expect(glucoseContextBucket("")).toBe(GLUCOSE_CONTEXT_UNSPECIFIED);
expect(glucoseContextBucket(" ")).toBe(GLUCOSE_CONTEXT_UNSPECIFIED);
expect(glucoseContextBucket("BEFORE_MEAL")).toBe(
GLUCOSE_CONTEXT_UNSPECIFIED,
);
});
it("keeps the named contexts named, case- and whitespace-tolerant", () => {
expect(glucoseContextBucket("FASTING")).toBe("FASTING");
expect(glucoseContextBucket(" bedtime ")).toBe("BEDTIME");
});
it("groups in canonical order with the untagged bucket last", () => {
const rows = [
{ ctx: null, v: 1 },
{ ctx: "BEDTIME", v: 2 },
{ ctx: "FASTING", v: 3 },
{ ctx: "", v: 4 },
];
const grouped = groupByGlucoseContext(rows, (row) => row.ctx);
expect(grouped.map(([bucket]) => bucket)).toEqual([
"FASTING",
"BEDTIME",
"UNSPECIFIED",
]);
// Input order survives inside a bucket, so a caller reading "latest" off
// the head keeps reading the same row.
expect(grouped[2][1].map((row) => row.v)).toEqual([1, 4]);
});
it("returns nothing for no rows", () => {
expect(groupByGlucoseContext([], () => null)).toEqual([]);
});
it("judges the untagged bucket against the RANDOM threshold", () => {
expect(thresholdMetricForContext(GLUCOSE_CONTEXT_UNSPECIFIED)).toBe(
"BLOOD_GLUCOSE_RANDOM",
);
});
});
@@ -12,7 +12,11 @@
* read plus the shared accumulators, so the emitted shape and ordering
* are unchanged.
*/
import { convertGlucose, type GlucoseUnit } from "@/lib/glucose";
import {
convertGlucose,
glucoseContextBucket,
type GlucoseUnit,
} from "@/lib/glucose";
import {
computeGlucoseClinicalMetrics,
GLUCOSE_PANEL_WINDOW_DAYS,
@@ -80,15 +84,18 @@ export function buildGlucoseBlock(ctx: Readonly<GlucoseBlockContext>): void {
meta: { cluster: "glucose", source: "glucose" },
});
} else {
// Group by context (NULL → "unspecified"), then per-day mean.
// Group by context (NULL → "unspecified"), then per-day mean. The bucket
// resolver is the shared one (#943) — this block named the untagged
// bucket first, and the rest of the tree now resolves through the same
// helper rather than repeating the NULL arm at each surface.
const byContext = new Map<
string,
Array<{ measuredAt: Date; value: number }>
>();
for (const r of glucoseRows) {
const ctxKey = r.glucoseContext
? String(r.glucoseContext).toLowerCase()
: "unspecified";
const ctxKey = glucoseContextBucket(
typeof r.glucoseContext === "string" ? r.glucoseContext : null,
).toLowerCase();
const list = byContext.get(ctxKey) ?? [];
list.push({ measuredAt: r.measuredAt, value: r.value });
byContext.set(ctxKey, list);
+10 -15
View File
@@ -49,6 +49,7 @@ import {
GLUCOSE_PANEL_WINDOW_DAYS,
type GlucoseClinicalMetrics,
} from "@/lib/analytics/glucose-metrics";
import { groupByGlucoseContext } from "@/lib/glucose";
import {
computeBpInTargetFastPath,
type BpInTargetEnvelope,
@@ -134,13 +135,6 @@ import {
unavailableWidgetIds,
} from "@/lib/dashboard/widget-modules";
const GLUCOSE_CONTEXTS = [
"FASTING",
"POSTPRANDIAL",
"RANDOM",
"BEDTIME",
] as const;
/**
* v1.7.0 — HealthLog `MeasurementType` → iOS `MetricKind` raw value.
*
@@ -832,15 +826,16 @@ async function buildExtras(
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true, glucoseContext: true },
});
// #943 — grouped through the shared bucket resolver, so readings with no
// meal-time tag land in `UNSPECIFIED` instead of being dropped on the floor.
const glucoseByContext: Record<string, DataSummary> = {};
if (glucoseRows.length > 0) {
for (const ctx of GLUCOSE_CONTEXTS) {
const ctxRows = glucoseRows.filter((r) => r.glucoseContext === ctx);
if (ctxRows.length === 0) continue;
glucoseByContext[ctx] = summarize(
ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })),
);
}
for (const [bucket, ctxRows] of groupByGlucoseContext(
glucoseRows,
(r) => r.glucoseContext,
)) {
glucoseByContext[bucket] = summarize(
ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })),
);
}
// v1.17.0 — clinical panel from the SAME 30-day glucose rows already read
@@ -1,4 +1,5 @@
import type { jsPDF } from "jspdf";
import { GLUCOSE_CONTEXT_BUCKETS } from "@/lib/glucose";
import autoTable from "jspdf-autotable";
import { convertGlucose, resolveGlucoseUnit } from "../glucose";
import type { MeasurementType } from "@/generated/prisma/client";
@@ -20,6 +21,8 @@ const GLUCOSE_LABEL_KEYS = {
POSTPRANDIAL: "doctorReport.typeGlucosePostprandial",
RANDOM: "doctorReport.typeGlucoseRandom",
BEDTIME: "doctorReport.typeGlucoseBedtime",
// #943 — readings the source never tagged with a meal time.
UNSPECIFIED: "doctorReport.typeGlucoseUnspecified",
} as const;
const DEFAULT_GLUCOSE_RANGES = {
@@ -27,13 +30,12 @@ const DEFAULT_GLUCOSE_RANGES = {
POSTPRANDIAL: { min: 70, max: 140 },
RANDOM: { min: 70, max: 140 },
BEDTIME: { min: 90, max: 150 },
// Same band as RANDOM: an untagged reading is a spot reading.
UNSPECIFIED: { min: 70, max: 140 },
} as const;
const GLUCOSE_CONTEXTS: Array<keyof typeof GLUCOSE_LABEL_KEYS> = [
"FASTING",
"POSTPRANDIAL",
"RANDOM",
"BEDTIME",
...GLUCOSE_CONTEXT_BUCKETS,
];
/** A single charted reading. */
+19 -14
View File
@@ -20,12 +20,14 @@ import {
getEffectiveRange,
type ThresholdOverridesJson,
} from "@/lib/analytics/effective-range";
import { resolveGlucoseUnit, thresholdMetricForContext } from "@/lib/glucose";
import {
GLUCOSE_CONTEXT_BUCKETS,
groupByGlucoseContext,
resolveGlucoseUnit,
thresholdMetricForContext,
} from "@/lib/glucose";
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import type {
GlucoseContext,
MeasurementType,
} from "@/generated/prisma/client";
import type { MeasurementType } from "@/generated/prisma/client";
import {
reconstructSleepNights,
type SleepStageRow,
@@ -70,13 +72,6 @@ import {
loadLabResults,
} from "./clinical-records";
const GLUCOSE_CONTEXTS: GlucoseContext[] = [
"FASTING",
"POSTPRANDIAL",
"RANDOM",
"BEDTIME",
];
const DENSE_REPORT_RAW_WINDOW_DAYS = 90;
/** No medication row is read unless at least one medication leaf is admitted. */
@@ -413,9 +408,19 @@ export async function collectDoctorReportData(
gender: userProfile?.gender ?? null,
};
if (glucosePanelOn) {
for (const ctx of GLUCOSE_CONTEXTS) {
// #943 — the untagged bucket is a bucket. A report built from a meter
// that records no meal-time context used to carry an empty glucose panel.
const groupedGlucose = groupByGlucoseContext(
glucoseRows,
(m) => m.glucoseContext,
);
const glucoseBuckets = denseGlucose
? GLUCOSE_CONTEXT_BUCKETS
: groupedGlucose.map(([bucket]) => bucket);
for (const ctx of glucoseBuckets) {
if (!denseGlucose) {
const rows = glucoseRows.filter((m) => m.glucoseContext === ctx);
const rows =
groupedGlucose.find(([bucket]) => bucket === ctx)?.[1] ?? [];
if (rows.length === 0) continue;
const values = rows.map((r) => r.value);
glucoseStats[ctx] = {
+6 -2
View File
@@ -9,6 +9,7 @@
* Split out of the aggregator with the selection rework.
*/
import { collapseMeasurementsToCanonical } from "@/lib/doctor-report-helpers";
import { glucoseContextBucket } from "@/lib/glucose";
import type { DoctorReportStats } from "@/lib/doctor-report-types";
import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import type { DenseMeasurementBucket } from "./dense-buckets";
@@ -117,8 +118,11 @@ export function summariseDenseBuckets(
}
for (const row of canonical) {
if (row.type !== "BLOOD_GLUCOSE" || !row.glucoseContext) continue;
accumulate(glucoseStatsAcc, row.glucoseContext, {
if (row.type !== "BLOOD_GLUCOSE") continue;
// #943 — a reading with no meal-time context is filed under the shared
// untagged bucket. Skipping it here dropped the whole panel for an
// account whose meter never records one.
accumulate(glucoseStatsAcc, glucoseContextBucket(row.glucoseContext), {
count: row.count,
sum: row.sumValue,
min: row.minValue,
+4
View File
@@ -77,6 +77,10 @@ export const GLUCOSE_LOINC: Record<string, { loinc: string; display: string }> =
},
RANDOM: { loinc: "2339-0", display: "Glucose [Mass/volume] in Blood" },
BEDTIME: { loinc: "2339-0", display: "Glucose [Mass/volume] in Blood" },
// #943 — the untagged bucket. Already named in the table above; without
// the entry the exported bundle silently dropped every reading whose
// source recorded no meal time.
UNSPECIFIED: { loinc: "2339-0", display: "Glucose [Mass/volume] in Blood" },
};
/* ── Clinical glucose-panel LOINCs (v1.18.0) ───────────────────────────
+87 -4
View File
@@ -49,11 +49,93 @@ export function resolveGlucoseUnit(
}
/**
* Threshold metric key corresponding to a glucose context. Keeps the
* The `GlucoseContext` enum members, in the order every surface iterates them.
*/
export const NAMED_GLUCOSE_CONTEXTS = [
"FASTING",
"POSTPRANDIAL",
"RANDOM",
"BEDTIME",
] as const;
export type NamedGlucoseContext = (typeof NAMED_GLUCOSE_CONTEXTS)[number];
/**
* The bucket every reading lands in whose `glucoseContext` column is NULL,
* blank, or not one of the four enum members.
*
* It is not a `GlucoseContext` value and never reaches the database — the
* column stays NULL. It exists because a reading without a meal-time tag is
* still a reading, and a surface that iterates only the named contexts drops
* it silently. That is not hypothetical: a meter synced through Apple Health
* writes no HealthKit meal-time metadata at all, so such an account has one
* hundred percent of its readings here and saw an empty dashboard, empty
* targets, and an empty doctor-report panel while the data was current
* (#943). The Coach's glucose block already grouped them under the same
* name — this promotes that one bucket to the shared notion the rest of the
* tree resolves through.
*/
export const GLUCOSE_CONTEXT_UNSPECIFIED = "UNSPECIFIED";
export type GlucoseContextBucket =
NamedGlucoseContext | typeof GLUCOSE_CONTEXT_UNSPECIFIED;
/** Canonical iteration order: the named contexts first, untagged last. */
export const GLUCOSE_CONTEXT_BUCKETS = [
...NAMED_GLUCOSE_CONTEXTS,
GLUCOSE_CONTEXT_UNSPECIFIED,
] as const;
/**
* Resolve a stored `glucose_context` cell to the bucket it belongs to.
* NULL, an empty string, and any value outside the enum all resolve to
* {@link GLUCOSE_CONTEXT_UNSPECIFIED} — the fail-open arm, because dropping
* a reading is worse than filing it under "no meal-time tag".
*/
export function glucoseContextBucket(
raw: string | null | undefined,
): GlucoseContextBucket {
if (raw == null) return GLUCOSE_CONTEXT_UNSPECIFIED;
const key = raw.trim().toUpperCase();
return (NAMED_GLUCOSE_CONTEXTS as readonly string[]).includes(key)
? (key as NamedGlucoseContext)
: GLUCOSE_CONTEXT_UNSPECIFIED;
}
/**
* Group rows into their glucose buckets, in {@link GLUCOSE_CONTEXT_BUCKETS}
* order, skipping buckets with no rows. Row order inside a bucket is the
* caller's input order, so a caller that reads "latest" off the head or tail
* keeps doing so.
*/
export function groupByGlucoseContext<T>(
rows: readonly T[],
contextOf: (row: T) => string | null | undefined,
): Array<[GlucoseContextBucket, T[]]> {
const byBucket = new Map<GlucoseContextBucket, T[]>();
for (const row of rows) {
const bucket = glucoseContextBucket(contextOf(row));
const list = byBucket.get(bucket);
if (list) list.push(row);
else byBucket.set(bucket, [row]);
}
return GLUCOSE_CONTEXT_BUCKETS.flatMap((bucket) => {
const list = byBucket.get(bucket);
return list ? [[bucket, list] as [GlucoseContextBucket, T[]]] : [];
});
}
/**
* Threshold metric key corresponding to a glucose bucket. Keeps the
* effective-range resolver the single source of truth.
*
* The untagged bucket borrows the RANDOM band: a reading with no meal-time
* tag IS a spot reading, which is exactly what RANDOM means, and inventing a
* fifth threshold would give the user a second editor row that judges the
* same readings by a different number.
*/
export function thresholdMetricForContext(
context: "FASTING" | "POSTPRANDIAL" | "RANDOM" | "BEDTIME",
context: GlucoseContextBucket,
):
| "BLOOD_GLUCOSE_FASTING"
| "BLOOD_GLUCOSE_POSTPRANDIAL"
@@ -64,9 +146,10 @@ export function thresholdMetricForContext(
return "BLOOD_GLUCOSE_FASTING";
case "POSTPRANDIAL":
return "BLOOD_GLUCOSE_POSTPRANDIAL";
case "RANDOM":
return "BLOOD_GLUCOSE_RANDOM";
case "BEDTIME":
return "BLOOD_GLUCOSE_BEDTIME";
case "RANDOM":
case GLUCOSE_CONTEXT_UNSPECIFIED:
return "BLOOD_GLUCOSE_RANDOM";
}
}
@@ -12,6 +12,18 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Derived } from "@/lib/insights/derived/types";
import type { ReadinessValue } from "@/lib/insights/derived/readiness";
/**
* The post-mutation tail reads the real `@/lib/db` client, not the fake this
* file injects, so a stored score opened a socket to a Postgres the unit job
* does not run. The attempt failed a few hundred milliseconds later and logged
* on the way out — after the test that started it had ended — which is how a
* green file tore its worker's RPC channel down mid-write. Stubbed here;
* `score-row.test.ts` asserts the wiring this stands for.
*/
vi.mock("@/lib/rollups/after-measurement-mutation", () => ({
afterMeasurementMutation: vi.fn(async () => {}),
}));
const computeReadinessMock =
vi.fn<(...args: unknown[]) => Promise<Derived<ReadinessValue>>>();
@@ -14,6 +14,27 @@ vi.mock("@/lib/logging/context", () => ({
annotate: (...args: unknown[]) => annotateMock(...args),
}));
/**
* The post-mutation tail talks to the real database, and this file does not.
*
* `upsertScoreRow` routes every stored score through
* `afterMeasurementMutation`, which reads the `@/lib/db` singleton rather than
* the client the caller injected. A unit test that hands in a fake Prisma
* therefore still opened a socket to a Postgres the unit job does not run: the
* attempt failed with ECONNREFUSED a few hundred milliseconds later and both
* legs logged on the way out, after the test that started them had ended. A
* console write that lands while the worker is closing its RPC channel fails
* the whole run with `EnvironmentTeardownError: Closing rpc while
* "onUserConsoleLog" was pending` — four of nine unit-suite failures in thirty
* days, every one of them with all tests green. Stubbing the tail removes the
* doomed I/O rather than the log line, and the call it made by accident is
* asserted below instead.
*/
vi.mock("@/lib/rollups/after-measurement-mutation", () => ({
afterMeasurementMutation: vi.fn(async () => {}),
}));
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import {
scoreDayKey,
scoreMeasuredAt,
@@ -92,6 +113,31 @@ describe("upsertScoreRow", () => {
measuredAt: arg.create.measuredAt,
});
});
it("routes the written row through the shared post-mutation tail", async () => {
// v1.37.19 wiring: a score row is a Measurement like any other, so the
// (type, day) rollup bucket and the cached status assessment converge on
// the tick that wrote it. Pinned here rather than left to a live database
// call that no unit job can serve.
const prisma = {
measurement: { upsert: vi.fn().mockResolvedValue({}) },
} as unknown as Parameters<typeof upsertScoreRow>[0];
await upsertScoreRow(prisma, {
userId: "u1",
type: "STRAIN_SCORE",
externalIdPrefix: "strain:",
score: 42,
now: new Date("2026-06-02T03:00:00Z"),
});
expect(afterMeasurementMutation).toHaveBeenCalledWith("u1", [
{
type: "STRAIN_SCORE",
measuredAt: new Date("2026-06-01T12:00:00.000Z"),
},
]);
});
});
describe("runScoreBatch", () => {
@@ -10,6 +10,18 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* The post-mutation tail reads the real `@/lib/db` client, not the fake this
* file injects, so a stored score opened a socket to a Postgres the unit job
* does not run. The attempt failed a few hundred milliseconds later and logged
* on the way out — after the test that started it had ended — which is how a
* green file tore its worker's RPC channel down mid-write. Stubbed here;
* `score-row.test.ts` asserts the wiring this stands for.
*/
vi.mock("@/lib/rollups/after-measurement-mutation", () => ({
afterMeasurementMutation: vi.fn(async () => {}),
}));
import {
strainDayKey,
strainExternalId,
@@ -10,6 +10,18 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* The post-mutation tail reads the real `@/lib/db` client, not the fake this
* file injects, so a stored score opened a socket to a Postgres the unit job
* does not run. The attempt failed a few hundred milliseconds later and logged
* on the way out — after the test that started it had ended — which is how a
* green file tore its worker's RPC channel down mid-write. Stubbed here;
* `score-row.test.ts` asserts the wiring this stands for.
*/
vi.mock("@/lib/rollups/after-measurement-mutation", () => ({
afterMeasurementMutation: vi.fn(async () => {}),
}));
const computeVitalsBaselineMock = vi.fn();
vi.mock("@/lib/insights/derived/baseline", () => ({
computeVitalsBaseline: (...args: unknown[]) =>
@@ -89,6 +89,10 @@ describe("loadOffhostConfig", () => {
expect(loadOffhostConfig()).toBeNull();
});
// No `retentionDays` here on purpose: the worker cannot enforce retention
// (DeleteObject is kept out of its grant), so the config carries no field
// for it and `BACKUP_RETENTION_DAYS` stays the operator's number for the
// bucket's own lifecycle rule.
it("parses a complete config", () => {
vi.stubEnv("BACKUP_S3_ENDPOINT", "https://r2.example");
vi.stubEnv("BACKUP_S3_BUCKET", "hl-backups");
@@ -102,7 +106,6 @@ describe("loadOffhostConfig", () => {
expect(cfg!.endpoint).toBe("https://r2.example");
expect(cfg!.region).toBe("auto");
expect(cfg!.encryptionKey.length).toBe(32);
expect(cfg!.retentionDays).toBe(30);
});
});
+9 -10
View File
@@ -13,7 +13,8 @@
*
* Retention: the worker NEVER calls DeleteObject on backup keys. Operators
* MUST configure a bucket-level lifecycle rule (e.g. expire after
* `BACKUP_RETENTION_DAYS`). This keeps the IAM grant for the worker
* `BACKUP_RETENTION_DAYS`, which this module reads nowhere — see
* `loadOffhostConfig`). This keeps the IAM grant for the worker
* limited to PutObject + GetObject + AbortMultipartUpload, so a compromised
* worker cannot wipe the backup history. The abort is what cleans up a run
* that failed partway rather than leaving billed, unlistable parts behind;
@@ -40,7 +41,6 @@ export interface OffhostBackupConfig {
secretKey: string;
region: string;
encryptionKey: Buffer;
retentionDays: number;
}
export class OffhostBackupNotConfiguredError extends Error {
@@ -69,13 +69,13 @@ export function loadOffhostConfig(): OffhostBackupConfig | null {
const encRaw = process.env.BACKUP_ENCRYPTION_KEY;
if (!endpoint || !bucket || !accessKey || !secretKey || !encRaw) return null;
const retentionDays = (() => {
const raw = process.env.BACKUP_RETENTION_DAYS;
if (!raw) return 30;
const v = parseInt(raw, 10);
return Number.isFinite(v) && v >= 1 ? v : 30;
})();
// `BACKUP_RETENTION_DAYS` is deliberately absent from this config. It used
// to be parsed and clamped here and then read by nobody, which read as an
// enforcer the worker is not: retention belongs to the bucket's lifecycle
// rule, and no consumer can ever appear here because DeleteObject is kept
// out of the worker's grant on purpose (see the header). The variable stays
// documented and on the compose whitelist because it is the number the
// operator sets that rule to.
return {
endpoint,
bucket,
@@ -83,7 +83,6 @@ export function loadOffhostConfig(): OffhostBackupConfig | null {
secretKey,
region: process.env.BACKUP_S3_REGION ?? "auto",
encryptionKey: decodeBackupKey(encRaw),
retentionDays,
};
}
@@ -390,6 +390,38 @@ describe("mapAppleHealthEntry", () => {
expect(out!.takenAt.toISOString()).toBe(ts);
});
it("stores a batch reading in the unit the table assumes, whatever `unit` says", () => {
// issue #944 — the batch route's `unit` is captured for audit and never
// read. A client that stamps the person's DISPLAY unit into the field
// must not have its metres multiplied by a thousand: the conversion is
// opt-in and only the `export.xml` path opts in.
const out = mapAppleHealthEntry({
hkIdentifier: "HKQuantityTypeIdentifierDistanceWalkingRunning",
value: 2484,
unit: "km",
startDate: ts,
endDate: ts,
});
expect(out).not.toBeNull();
expect(out!.value).toBe(2484);
expect(out!.unit).toBe("m");
});
it("converts out of the record's own unit when the caller opts in", () => {
const out = mapAppleHealthEntry(
{
hkIdentifier: "HKQuantityTypeIdentifierDistanceWalkingRunning",
value: 2.484,
unit: "km",
startDate: ts,
endDate: ts,
},
{ convertRecordUnit: true },
);
expect(out).not.toBeNull();
expect(out!.value).toBeCloseTo(2484, 9);
});
it("applies the unit conversion for fraction-shaped quantities", () => {
const out = mapAppleHealthEntry({
hkIdentifier: "HKQuantityTypeIdentifierOxygenSaturation",
@@ -0,0 +1,79 @@
import { describe, it, expect } from "vitest";
import {
convertHkValue,
hkDistanceToMetres,
isConvertibleHkUnit,
} from "../hk-units";
describe("convertHkValue", () => {
it("converts the km reading Apple writes on a metric archive", () => {
expect(convertHkValue(2.484, "km", "m")).toBeCloseTo(2484, 9);
});
it("converts the mi reading Apple writes on an imperial archive", () => {
expect(convertHkValue(1.543, "mi", "m")).toBeCloseTo(2483.217792, 9);
});
it("returns the reading untouched when the units already agree", () => {
expect(convertHkValue(2.484, "m", "m")).toBe(2.484);
});
it("converts mass, energy and temperature the same way", () => {
expect(convertHkValue(180, "lb", "kg")).toBeCloseTo(81.6466266, 6);
expect(convertHkValue(1000, "kJ", "kcal")).toBeCloseTo(239.005736, 6);
expect(convertHkValue(98.6, "degF", "degC")).toBeCloseTo(37, 9);
});
it("strips the molar mass Apple annotates a glucose unit with", () => {
expect(convertHkValue(5.5, "mmol<180.156>/L", "mg/dL")).toBeCloseTo(
99.0858,
4,
);
expect(isConvertibleHkUnit("mmol<180>/L")).toBe(true);
expect(isConvertibleHkUnit("mmol<x>/L")).toBe(false);
});
it("keeps the small calorie apart from the large one", () => {
expect(convertHkValue(1, "Cal", "kcal")).toBe(1);
expect(convertHkValue(1000, "cal", "kcal")).toBeCloseTo(1, 9);
});
it("refuses a unit it cannot place rather than guessing", () => {
expect(convertHkValue(1, "furlong", "m")).toBeNull();
expect(convertHkValue(1, "", "m")).toBeNull();
expect(convertHkValue(1, undefined, "m")).toBeNull();
});
it("refuses to cross families", () => {
expect(convertHkValue(1, "kg", "m")).toBeNull();
expect(convertHkValue(1, "min", "kcal")).toBeNull();
});
it("refuses a non-finite reading", () => {
expect(convertHkValue(Number.NaN, "km", "m")).toBeNull();
});
});
describe("hkDistanceToMetres", () => {
it("converts the workout distance units Apple ships", () => {
expect(hkDistanceToMetres(6.5, "km")).toBeCloseTo(6500, 9);
expect(hkDistanceToMetres(3, "mi")).toBeCloseTo(4828.032, 9);
expect(hkDistanceToMetres(500, "m")).toBe(500);
});
it("falls back to the raw number for a missing unit attribute", () => {
expect(hkDistanceToMetres(500, "")).toBe(500);
expect(hkDistanceToMetres(500, undefined)).toBe(500);
});
});
describe("isConvertibleHkUnit", () => {
it("recognises the units the mapping table pins", () => {
expect(isConvertibleHkUnit("m")).toBe(true);
expect(isConvertibleHkUnit("kg")).toBe(true);
expect(isConvertibleHkUnit("count")).toBe(false);
expect(isConvertibleHkUnit("event")).toBe(false);
expect(isConvertibleHkUnit("%")).toBe(false);
});
});
@@ -1278,3 +1278,67 @@ describe("streamParseExportXml — the archive's own export stamp", () => {
expect(stamps).toEqual([]);
});
});
describe("streamParseExportXml — the record's own unit (issue #944)", () => {
it("stores a km-scale walking distance in metres", async () => {
const { prisma } = await importCumulativeFixture(
cumulativeExportXml(
"HKQuantityTypeIdentifierDistanceWalkingRunning",
"km",
[{ value: 2.484, sourceName: "iPhone", hour: 8 }],
),
);
expect(prisma._measurements).toHaveLength(1);
expect(prisma._measurements[0]).toMatchObject({
type: "WALKING_RUNNING_DISTANCE",
unit: "m",
});
expect(prisma._measurements[0].value).toBeCloseTo(2484, 6);
});
it("stores a kilojoule workout burn in kilocalories", async () => {
const tmp = mkdtempSync(join(tmpdir(), "healthlog-parser-energy-test-"));
const xmlPath = join(tmp, "export.xml");
writeFileSync(
xmlPath,
`<?xml version="1.0" encoding="UTF-8"?>
<HealthData locale="en_AU">
<Workout workoutActivityType="HKWorkoutActivityTypeRunning"
duration="42.0"
durationUnit="min"
totalDistance="6.5"
totalDistanceUnit="km"
totalEnergyBurned="1724"
totalEnergyBurnedUnit="kJ"
startDate="2026-05-14 18:00:00 +0200"
endDate="2026-05-14 18:42:00 +0200"
sourceName="Apple Watch"/>
</HealthData>`,
);
const prisma = makeFakePrisma();
await streamParseExportXml({
xmlPath,
userId: "user-energy",
userTimezone: "Europe/Berlin",
prisma: prisma as unknown as PrismaClient,
});
expect(prisma._workouts).toHaveLength(1);
expect(prisma._workouts[0].totalDistanceM).toBeCloseTo(6500, 6);
expect(prisma._workouts[0].totalEnergyKcal).toBeCloseTo(412.0458891, 6);
});
it("stores a mile-scale walking distance in metres", async () => {
const { prisma } = await importCumulativeFixture(
cumulativeExportXml(
"HKQuantityTypeIdentifierDistanceWalkingRunning",
"mi",
[{ value: 1.543, sourceName: "iPhone", hour: 8 }],
),
);
expect(prisma._measurements).toHaveLength(1);
expect(prisma._measurements[0].value).toBeCloseTo(2483.217792, 6);
});
});
+117 -4
View File
@@ -28,6 +28,7 @@ import type {
SleepStage,
RhythmClassification,
} from "@/generated/prisma/client";
import { convertHkValue } from "./hk-units";
/**
* How a batch of HealthKit samples for the same identifier should be
@@ -47,6 +48,22 @@ export interface AppleHealthMapping {
measurementType: MeasurementType;
/** Apple's HK unit string (e.g. `kg`, `count/min`, `mL/(kg*min)`). */
hkUnit: string;
/**
* issue #944 — why this entry's `hkUnit` can be trusted as fixed.
*
* Apple's `export.xml` writes the ACCOUNT's display unit on every
* quantity `<Record>`, not the unit this table assumes, so
* `mapAppleHealthEntry()` converts the reading from the record's own
* unit into `hkUnit` before `convertToDbUnit` runs — on the archive
* path, which is the caller that opts in. That only works for a unit
* `convertHkValue()` knows a factor for. An entry whose `hkUnit`
* is NOT convertible (a dimensionless count, a pinned event, a
* logarithmic dB level, a compound rate) must say here why no record
* unit can change its reading — the structural guard
* `src/__tests__/apple-health-unit-audit-guard.test.ts` fails on an
* entry that offers neither.
*/
unitFixedReason?: string;
/** HealthLog canonical DB unit (must match `getUnitForType()`). */
dbUnit: string;
/** Convert a HK sample value into the canonical DB unit. Identity for most. */
@@ -189,6 +206,28 @@ export const APPLE_HEALTH_SLEEP_STAGE_MAP: Record<number, SleepStage> = {
* metric: check Apple's HK unit; percent → ×100 path, m/m·s/kg/etc
* → identity path.
*/
/**
* issue #944 — the written reasons a mapping entry may cite for keeping a
* fixed `hkUnit`. Each one answers the same question: what could Apple
* possibly write in this record's `unit` attribute that would change the
* reading? Shared constants rather than free text so the audit reads as
* one decision per class instead of forty near-duplicates.
*/
const UNIT_FIXED_DIMENSIONLESS_COUNT =
"Dimensionless tally: Apple emits `count` in every locale and HKUnit has no sibling spelling for it, so no record unit can rescale the reading.";
const UNIT_FIXED_PER_MINUTE =
"Per-minute rate: Apple emits `count/min` in every locale and the Health app offers no alternative display unit, so there is nothing to convert from.";
const UNIT_FIXED_PERCENT_FRACTION =
"Apple writes the percent unit but the VALUE rides as a 0..1 fraction; the x100 in `convertToDbUnit` already covers both spellings, so branching on the record unit would double-scale it.";
const UNIT_FIXED_EVENT_PIN =
"Categorical event: `convertToDbUnit` pins the stored value to 1 fired event regardless of the reading, so no unit attribute can move it.";
const UNIT_FIXED_AUDIO_SPL =
"Sound-pressure level: Apple emits `dBASPL` only, and a decibel is logarithmic — a sibling unit would need its own formula, never a factor.";
const UNIT_FIXED_COMPOSITE_VO2 =
"Compound rate Apple emits verbatim as `mL/(kg*min)`; the Health app exposes no other spelling and the compound has no sibling unit to convert from.";
const UNIT_FIXED_SLEEP_CATEGORY =
"Sleep samples carry a symbolic category value, not a quantity: the importer derives the duration in minutes from the sample's own dates, so the record's unit attribute (empty in Apple's archive) says nothing about the reading.";
export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
// ── Body composition ────────────────────────────────────────
HKQuantityTypeIdentifierBodyMass: {
@@ -203,6 +242,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierBodyFatPercentage",
measurementType: "BODY_FAT",
hkUnit: "%",
unitFixedReason: UNIT_FIXED_PERCENT_FRACTION,
dbUnit: "%",
// Apple ships 0..1 fraction; HealthLog stores 0..100.
convertToDbUnit: (v) => v * 100,
@@ -254,6 +294,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierHeartRate",
measurementType: "PULSE",
hkUnit: "count/min",
unitFixedReason: UNIT_FIXED_PER_MINUTE,
dbUnit: "bpm",
convertToDbUnit: (v) => v,
aggregation: "latest",
@@ -262,6 +303,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierRestingHeartRate",
measurementType: "RESTING_HEART_RATE",
hkUnit: "count/min",
unitFixedReason: UNIT_FIXED_PER_MINUTE,
dbUnit: "bpm",
convertToDbUnit: (v) => v,
aggregation: "latest",
@@ -284,6 +326,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
// duplication; see `.planning/phase-W1-v1423-research.md`).
measurementType: "ACTIVITY_STEPS",
hkUnit: "count",
unitFixedReason: UNIT_FIXED_DIMENSIONLESS_COUNT,
dbUnit: "steps",
convertToDbUnit: (v) => v,
aggregation: "sum",
@@ -300,6 +343,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierFlightsClimbed",
measurementType: "FLIGHTS_CLIMBED",
hkUnit: "count",
unitFixedReason: UNIT_FIXED_DIMENSIONLESS_COUNT,
dbUnit: "flights",
convertToDbUnit: (v) => v,
aggregation: "sum",
@@ -318,6 +362,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierVO2Max",
measurementType: "VO2_MAX",
hkUnit: "mL/(kg*min)",
unitFixedReason: UNIT_FIXED_COMPOSITE_VO2,
dbUnit: "mL/(kg·min)",
convertToDbUnit: (v) => v,
aggregation: "latest",
@@ -337,6 +382,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierOxygenSaturation",
measurementType: "OXYGEN_SATURATION",
hkUnit: "fraction",
unitFixedReason: UNIT_FIXED_PERCENT_FRACTION,
dbUnit: "%",
// Apple ships 0..1 fraction; HealthLog stores 0..100.
convertToDbUnit: (v) => v * 100,
@@ -348,6 +394,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis",
measurementType: "SLEEP_DURATION",
hkUnit: "category",
unitFixedReason: UNIT_FIXED_SLEEP_CATEGORY,
dbUnit: "minutes",
// value here is duration-in-minutes (caller computes
// `endDate - startDate` and converts to minutes); the per-stage
@@ -367,6 +414,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierEnvironmentalAudioExposure",
measurementType: "AUDIO_EXPOSURE_ENV",
hkUnit: "dBASPL",
unitFixedReason: UNIT_FIXED_AUDIO_SPL,
dbUnit: "dBA",
convertToDbUnit: (v) => v,
aggregation: "mean",
@@ -379,6 +427,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierHeadphoneAudioExposure",
measurementType: "AUDIO_EXPOSURE_HEADPHONE",
hkUnit: "dBASPL",
unitFixedReason: UNIT_FIXED_AUDIO_SPL,
dbUnit: "dBA",
convertToDbUnit: (v) => v,
aggregation: "mean",
@@ -403,6 +452,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierAppleWalkingSteadiness",
measurementType: "WALKING_STEADINESS",
hkUnit: "%",
unitFixedReason: UNIT_FIXED_PERCENT_FRACTION,
dbUnit: "%",
// Apple ships 0..1 fraction; HealthLog stores 0..100.
convertToDbUnit: (v) => v * 100,
@@ -416,6 +466,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent",
measurementType: "AUDIO_EXPOSURE_EVENT",
hkUnit: "count",
unitFixedReason: UNIT_FIXED_EVENT_PIN,
dbUnit: "count",
convertToDbUnit: () => 1,
aggregation: "sum",
@@ -427,6 +478,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKCategoryTypeIdentifierHeadphoneAudioExposureEvent",
measurementType: "AUDIO_EXPOSURE_EVENT",
hkUnit: "count",
unitFixedReason: UNIT_FIXED_EVENT_PIN,
dbUnit: "count",
convertToDbUnit: () => 1,
aggregation: "sum",
@@ -449,6 +501,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKCategoryTypeIdentifierIrregularHeartRhythmEvent",
measurementType: "IRREGULAR_RHYTHM_NOTIFICATION",
hkUnit: "event",
unitFixedReason: UNIT_FIXED_EVENT_PIN,
dbUnit: "event",
convertToDbUnit: () => 1,
aggregation: "sum",
@@ -462,6 +515,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKCategoryTypeIdentifierHighHeartRateEvent",
measurementType: "HIGH_HEART_RATE_EVENT",
hkUnit: "event",
unitFixedReason: UNIT_FIXED_EVENT_PIN,
dbUnit: "event",
convertToDbUnit: () => 1,
aggregation: "sum",
@@ -474,6 +528,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKCategoryTypeIdentifierLowHeartRateEvent",
measurementType: "LOW_HEART_RATE_EVENT",
hkUnit: "event",
unitFixedReason: UNIT_FIXED_EVENT_PIN,
dbUnit: "event",
convertToDbUnit: () => 1,
aggregation: "sum",
@@ -487,6 +542,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKCategoryTypeIdentifierAppleWalkingSteadinessEvent",
measurementType: "WALKING_STEADINESS_EVENT",
hkUnit: "event",
unitFixedReason: UNIT_FIXED_EVENT_PIN,
dbUnit: "event",
convertToDbUnit: () => 1,
aggregation: "sum",
@@ -501,6 +557,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKCategoryTypeIdentifierSleepApneaEvent",
measurementType: "BREATHING_DISTURBANCE_EVENT",
hkUnit: "event",
unitFixedReason: UNIT_FIXED_EVENT_PIN,
dbUnit: "event",
convertToDbUnit: () => 1,
aggregation: "sum",
@@ -524,6 +581,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierRespiratoryRate",
measurementType: "RESPIRATORY_RATE",
hkUnit: "count/min",
unitFixedReason: UNIT_FIXED_PER_MINUTE,
dbUnit: "breaths/min",
convertToDbUnit: (v) => v,
aggregation: "mean",
@@ -537,6 +595,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierBodyMassIndex",
measurementType: "BODY_MASS_INDEX",
hkUnit: "count",
unitFixedReason: UNIT_FIXED_DIMENSIONLESS_COUNT,
dbUnit: "kg/m²",
convertToDbUnit: (v) => v,
aggregation: "latest",
@@ -557,6 +616,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierWalkingHeartRateAverage",
measurementType: "WALKING_HEART_RATE_AVERAGE",
hkUnit: "count/min",
unitFixedReason: UNIT_FIXED_PER_MINUTE,
dbUnit: "bpm",
convertToDbUnit: (v) => v,
aggregation: "mean",
@@ -570,6 +630,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierWalkingAsymmetryPercentage",
measurementType: "WALKING_ASYMMETRY",
hkUnit: "%",
unitFixedReason: UNIT_FIXED_PERCENT_FRACTION,
dbUnit: "%",
convertToDbUnit: (v) => v * 100,
aggregation: "latest",
@@ -580,6 +641,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierWalkingDoubleSupportPercentage",
measurementType: "WALKING_DOUBLE_SUPPORT",
hkUnit: "%",
unitFixedReason: UNIT_FIXED_PERCENT_FRACTION,
dbUnit: "%",
convertToDbUnit: (v) => v * 100,
aggregation: "latest",
@@ -622,6 +684,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierHeartRateRecoveryOneMinute",
measurementType: "CARDIO_RECOVERY",
hkUnit: "count/min",
unitFixedReason: UNIT_FIXED_PER_MINUTE,
dbUnit: "bpm",
convertToDbUnit: (v) => v,
aggregation: "latest",
@@ -645,6 +708,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierNumberOfTimesFallen",
measurementType: "FALL_COUNT",
hkUnit: "count",
unitFixedReason: UNIT_FIXED_DIMENSIONLESS_COUNT,
dbUnit: "count",
convertToDbUnit: (v) => v,
aggregation: "sum",
@@ -685,6 +749,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
hkIdentifier: "HKQuantityTypeIdentifierAppleSleepingBreathingDisturbances",
measurementType: "BREATHING_DISTURBANCES",
hkUnit: "count",
unitFixedReason: UNIT_FIXED_DIMENSIONLESS_COUNT,
dbUnit: "count",
convertToDbUnit: (v) => v,
aggregation: "latest",
@@ -1140,7 +1205,14 @@ export interface AppleHealthEntryInput {
hkIdentifier: string;
/** Numeric value as Apple delivers it (pre-conversion). */
value: number;
/** Apple's unit string, captured for audit; not currently validated. */
/**
* Apple's unit string for this reading, captured for audit.
*
* Read ONLY when the caller opts in with `convertRecordUnit` — see
* `AppleHealthEntryOptions`. Every other caller gets the historical
* contract: the field is recorded and the reading is taken to already
* be in the table's `hkUnit`.
*/
unit: string;
/** ISO timestamp string (e.g. HealthKit `startDate`). */
startDate: string;
@@ -1175,6 +1247,33 @@ export interface AppleHealthEntryInput {
valueMax?: number;
}
/**
* Per-call options for `mapAppleHealthEntry()`.
*/
export interface AppleHealthEntryOptions {
/**
* Convert the reading out of `input.unit` and into the table's `hkUnit`
* before `convertToDbUnit` runs. Default FALSE.
*
* issue #944 — only the `export.xml` archive path passes `true`, because
* only there is the unit attribute known to be authoritative: Apple
* stamps every quantity `<Record>` with the ACCOUNT's display unit, so a
* metric archive reports walking distance in `km` and the number is
* meaningless without it.
*
* `POST /api/measurements/batch` deliberately does NOT opt in. Its `unit`
* is documented as captured-for-audit, not validated, and the native
* client's unit strings for the seventeen convertible identifiers are not
* pinned by anything in this repository — no fixture, no contract test, no
* coordination note. A client that reads a sample in metres and stamps the
* person's DISPLAY unit (`km`) into the field would have every walking
* distance multiplied by a thousand on ingest. Reading the field there is
* a wire change, and a wire change needs the client's strings pinned
* first.
*/
convertRecordUnit?: boolean;
}
/** Output of `mapAppleHealthEntry()`. */
export interface AppleHealthEntryOutput {
type: MeasurementType;
@@ -1214,6 +1313,7 @@ export interface AppleHealthEntryOutput {
*/
export function mapAppleHealthEntry(
input: AppleHealthEntryInput,
options: AppleHealthEntryOptions = {},
): AppleHealthEntryOutput | null {
const mapping = APPLE_HEALTH_TYPE_MAP[input.hkIdentifier];
if (!mapping) return null;
@@ -1221,7 +1321,20 @@ export function mapAppleHealthEntry(
const takenAt = new Date(input.endDate);
if (Number.isNaN(takenAt.getTime())) return null;
const value = mapping.convertToDbUnit(input.value);
// issue #944 — `convertToDbUnit` is written against the table's fixed
// `hkUnit`, but Apple's `export.xml` stamps each `<Record>` with the
// ACCOUNT's own display unit: a metric archive reports walking distance
// in `km`, an imperial one in `mi`, body mass in `lb`, energy in `kJ`.
// The archive path opts in and the reading is normalised into `hkUnit`
// first; a unit we cannot place leaves the value untouched (see
// `convertHkValue`). Every other caller keeps the identity behaviour —
// see `AppleHealthEntryOptions.convertRecordUnit` for why.
const toHkUnit = (raw: number): number =>
options.convertRecordUnit
? (convertHkValue(raw, input.unit, mapping.hkUnit) ?? raw)
: raw;
const value = mapping.convertToDbUnit(toHkUnit(input.value));
const out: AppleHealthEntryOutput = {
type: mapping.measurementType,
@@ -1235,10 +1348,10 @@ export function mapAppleHealthEntry(
// hourly HR bucket). A finite check guards a malformed payload; the
// caller only persists these on the HR-bucket row.
if (typeof input.valueMin === "number" && Number.isFinite(input.valueMin)) {
out.valueMin = mapping.convertToDbUnit(input.valueMin);
out.valueMin = mapping.convertToDbUnit(toHkUnit(input.valueMin));
}
if (typeof input.valueMax === "number" && Number.isFinite(input.valueMax)) {
out.valueMax = mapping.convertToDbUnit(input.valueMax);
out.valueMax = mapping.convertToDbUnit(toHkUnit(input.valueMax));
}
if (mapping.sleepStageMap) {
+187
View File
@@ -0,0 +1,187 @@
/**
* HealthKit unit normalisation (issue #944).
*
* Apple writes a `unit` attribute on every quantity `<Record>` of an
* `export.xml` archive, and the string it writes is the account's OWN
* display unit rather than the unit HealthLog's mapping table assumes:
* a metric account exports walking distance as `km`, an imperial one as
* `mi`, and the same split shows up on body mass (`kg` / `lb` / `st`),
* energy (`kcal` / `kJ`) and temperature (`degC` / `degF`).
*
* Before this module the export import fed the raw number straight into
* `AppleHealthMapping.convertToDbUnit`, which is written against the
* table's fixed `hkUnit` — so a 2.484 km day was stored as 2.484 m. The
* workout path had a hand-rolled km/mi→m branch of its own; both paths
* now share `convertHkValue()`.
*
* Contract, deliberately conservative:
*
* - Same unit in and out → the value is returned untouched, so an
* archive (or an iOS batch) that already speaks the table's unit is
* byte-for-byte unchanged.
* - Unknown unit, empty unit, or two units from different families →
* `null`, and every caller falls back to the raw value. A unit we
* cannot prove a factor for must never silently rescale a reading.
* - Only units Apple's `HKUnit` can actually emit are listed. A unit
* string is matched exactly (after trimming and after stripping the
* molar-mass annotation Apple writes on glucose), never lower-cased:
* `cal` (small calorie) and `Cal` (kilocalorie) are different units
* that differ only in case.
*/
interface HkUnitSpec {
/** Units convert only within one family. */
family: string;
/** This unit → the family's base unit. */
toBase: (value: number) => number;
/** The family's base unit → this unit. */
fromBase: (value: number) => number;
}
/** A unit that is a plain multiple of its family's base unit. */
function scaled(family: string, perBase: number): HkUnitSpec {
return {
family,
toBase: (value) => value * perBase,
fromBase: (value) => value / perBase,
};
}
/**
* Every unit string Apple emits that HealthLog can convert, keyed by the
* exact `HKUnit.unitString` spelling. Base units: metre, kilogram,
* kilocalorie, second, degree Celsius, mmHg, mg/dL, metres per second.
*/
const HK_UNITS: Record<string, HkUnitSpec> = {
// ── Length (base: metre) ───────────────────────────────────
m: scaled("length", 1),
km: scaled("length", 1000),
cm: scaled("length", 0.01),
mm: scaled("length", 0.001),
in: scaled("length", 0.0254),
ft: scaled("length", 0.3048),
yd: scaled("length", 0.9144),
mi: scaled("length", 1609.344),
// ── Mass (base: kilogram) ──────────────────────────────────
kg: scaled("mass", 1),
g: scaled("mass", 0.001),
lb: scaled("mass", 0.45359237),
oz: scaled("mass", 0.028349523125),
st: scaled("mass", 6.35029318),
// ── Energy (base: kilocalorie) ─────────────────────────────
kcal: scaled("energy", 1),
Cal: scaled("energy", 1), // Apple's large calorie == kilocalorie
cal: scaled("energy", 0.001), // small calorie — case is load-bearing
kJ: scaled("energy", 1 / 4.184),
J: scaled("energy", 1 / 4184),
// ── Time (base: second) ────────────────────────────────────
s: scaled("time", 1),
ms: scaled("time", 0.001),
min: scaled("time", 60),
hr: scaled("time", 3600),
d: scaled("time", 86_400),
// ── Pressure (base: mmHg) ──────────────────────────────────
mmHg: scaled("pressure", 1),
kPa: scaled("pressure", 7.500_616_827_041_698),
Pa: scaled("pressure", 0.007_500_616_827_041_698),
// ── Blood glucose (base: mg/dL) ────────────────────────────
// Apple annotates the molar unit with glucose's molar mass, which the
// normaliser strips before the lookup.
"mg/dL": scaled("glucose", 1),
"mmol/L": scaled("glucose", 18.0156), // 180.156 g/mol ÷ 10
// ── Speed (base: metres per second) ────────────────────────
"m/s": scaled("speed", 1),
"km/hr": scaled("speed", 1000 / 3600),
"mi/hr": scaled("speed", 1609.344 / 3600),
// ── Temperature (base: degree Celsius) — affine, not scaled ─
degC: {
family: "temperature",
toBase: (value) => value,
fromBase: (value) => value,
},
degF: {
family: "temperature",
toBase: (value) => ((value - 32) * 5) / 9,
fromBase: (value) => (value * 9) / 5 + 32,
},
K: {
family: "temperature",
toBase: (value) => value - 273.15,
fromBase: (value) => value + 273.15,
},
};
/**
* Apple writes molar units with the substance's molar mass inline —
* `mmol<180.156>/L` for blood glucose. The annotation is a number and
* informational; strip exactly that shape so the lookup sees the plain
* unit. Anything else between angle brackets is not a unit Apple writes
* and stays unknown.
*/
const MOLAR_MASS_ANNOTATION = /<\d+(?:\.\d+)?>/g;
function normaliseUnitString(unit: string): string {
return unit.trim().replace(MOLAR_MASS_ANNOTATION, "");
}
function lookup(unit: string | null | undefined): HkUnitSpec | null {
if (typeof unit !== "string") return null;
const normalised = normaliseUnitString(unit);
if (!normalised) return null;
return HK_UNITS[normalised] ?? null;
}
/**
* `true` when this module knows a conversion factor for the unit — i.e.
* a `<Record>` that carries a sibling unit from the same family will be
* converted rather than trusted blindly. The mapping-table guard in
* `src/__tests__/apple-health-unit-audit-guard.test.ts` uses this to
* decide whether an entry's fixed `hkUnit` needs a written reason.
*/
export function isConvertibleHkUnit(unit: string | null | undefined): boolean {
return lookup(unit) !== null;
}
/**
* Convert a HealthKit value between two `HKUnit` strings. Returns `null`
* — never a guess — when either unit is unknown or the two belong to
* different families; callers keep the raw value in that case.
*/
export function convertHkValue(
value: number,
fromUnit: string | null | undefined,
toUnit: string,
): number | null {
if (!Number.isFinite(value)) return null;
const from = lookup(fromUnit);
if (!from) return null;
const to = lookup(toUnit);
if (!to) return null;
if (from.family !== to.family) return null;
// Same unit: return the reading untouched rather than round-tripping it
// through the base and picking up a float error.
if (normaliseUnitString(String(fromUnit)) === normaliseUnitString(toUnit)) {
return value;
}
return to.fromBase(from.toBase(value));
}
/**
* Convert a distance a HealthKit archive reports in its own unit into
* metres, the canonical DB unit. Falls back to the raw number for a unit
* this module cannot place (including the empty attribute an archive may
* write) — the pre-#944 behaviour of the workout path, preserved.
*/
export function hkDistanceToMetres(
value: number,
unit: string | null | undefined,
): number {
return convertHkValue(value, unit, "m") ?? value;
}
@@ -42,6 +42,10 @@ import {
} from "@/lib/measurements/drain-per-sample-cumulative";
import { reconcileExternalMeasurement } from "@/lib/measurements/reconcile-external-measurement";
import { resolveHkWorkoutSportType } from "@/lib/measurements/hk-workout-activity-type-map";
import {
convertHkValue,
hkDistanceToMetres,
} from "@/lib/measurements/hk-units";
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
@@ -851,14 +855,21 @@ export async function streamParseExportXml(
return;
}
const mapped = mapAppleHealthEntry({
hkIdentifier: hkType,
value: parsedValue.value,
unit: attrs.unit ?? mapping.hkUnit,
startDate: attrs.startDate,
endDate: attrs.endDate,
sleepStage: parsedValue.sleepStage,
});
const mapped = mapAppleHealthEntry(
{
hkIdentifier: hkType,
value: parsedValue.value,
unit: attrs.unit ?? mapping.hkUnit,
startDate: attrs.startDate,
endDate: attrs.endDate,
sleepStage: parsedValue.sleepStage,
},
// issue #944 — the archive is the one caller whose unit attribute
// is authoritative: Apple writes the account's own display unit on
// every quantity `<Record>`, so `km` here means kilometres and the
// reading has to be converted before it is stored.
{ convertRecordUnit: true },
);
if (!mapped) {
unknown[`${hkType}::map_failed`] =
(unknown[`${hkType}::map_failed`] ?? 0) + 1;
@@ -947,14 +958,13 @@ export async function streamParseExportXml(
const totalDistance = Number.parseFloat(attrs.totalDistance ?? "");
const totalEnergy = Number.parseFloat(attrs.totalEnergyBurned ?? "");
const distanceUnit = attrs.totalDistanceUnit ?? "";
// Apple ships km for HKWorkout.totalDistance by default; metres
// is the canonical DB unit. Convert when the unit is km.
const energyUnit = attrs.totalEnergyBurnedUnit ?? "";
// Apple ships the ACCOUNT's own length unit on HKWorkout.totalDistance
// (km for a metric account, mi for an imperial one); metres is the
// canonical DB unit. issue #944 — the same conversion the `<Record>`
// path now runs, so the two can never drift apart again.
const distanceM = Number.isFinite(totalDistance)
? distanceUnit === "km"
? totalDistance * 1000
: distanceUnit === "mi"
? totalDistance * 1609.344
: totalDistance
? hkDistanceToMetres(totalDistance, distanceUnit)
: null;
const externalId = hashSampleKey(
@@ -970,7 +980,13 @@ export async function streamParseExportXml(
startedAt: startDate,
endedAt: endDate,
durationSec,
totalEnergyKcal: Number.isFinite(totalEnergy) ? totalEnergy : null,
totalEnergyKcal: Number.isFinite(totalEnergy)
? // issue #944 — the energy attribute carries the account's own
// unit too: a Health app set to kilojoules writes `kJ` here, and
// the column is kilocalories. Same shared conversion; an unknown
// unit leaves the number alone.
(convertHkValue(totalEnergy, energyUnit, "kcal") ?? totalEnergy)
: null,
totalDistanceM: distanceM,
externalId,
externalSourceVersion: attrs.sourceVersion ?? null,
@@ -0,0 +1,395 @@
/**
* Repair for walking distances the `export.zip` import stored 1000x too
* small (issue #944).
*
* THE DEFECT (fixed in code from this release): `mapAppleHealthEntry()`
* accepted the `<Record unit="…">` attribute and never read it, so a
* `HKQuantityTypeIdentifierDistanceWalkingRunning` record — which Apple
* writes in the account's own display unit, `km` on a metric archive —
* was folded into the day's total as if the number were already metres.
* Every day of an imported history reads as a couple of metres walked.
*
* THE CODE FIX converts the record's own unit into the table's `hkUnit`
* before storage, so every FUTURE import is right. This module heals what
* is already in the database.
*
* ── The criterion ──────────────────────────────────────────────────
*
* A row is a candidate only when it carries the stamp the export importer
* itself writes, never because its value "looks small":
*
* type = WALKING_RUNNING_DISTANCE
* source = APPLE_HEALTH
* aggregationProvenance = EXPORT_XML_SOURCE_MAX
* externalId LIKE stats:HKQuantityTypeIdentifierDistanceWalkingRunning:%
* deletedAt IS NULL
*
* `EXPORT_XML_SOURCE_MAX` is written in exactly one place — the archive
* fold in `import-apple-health-export.ts` — and the `stats:<HK>:<day>`
* externalId is minted by `dailyStatsExternalId()` for that same fold. A
* row a native iOS sync has since overwritten carries
* `HEALTHKIT_STATISTICS` and its value came from HealthKit's own
* statistics query, so it is correct and out of scope. A row whose
* provenance is `LEGACY_UNKNOWN` or NULL cannot be PROVEN to have come
* from the archive path and is therefore left alone: re-importing the
* archive on the fixed build repairs those, and is the authoritative
* repair for any account that still has its `export.zip`.
*
* ── The refusal ────────────────────────────────────────────────────
*
* The criterion proves an archive ORIGIN. It cannot prove the archive was
* imported on the broken build — an account re-imported since carries the
* same stamp with the right numbers, and its rest days still pass the
* plausibility check after another x1000. So any candidate whose repaired
* value would leave the range refuses the whole account: nothing is
* written, the rows are reported, and the operator decides. Re-importing
* the archive is the authoritative repair and needs no script.
*
* ── Idempotency ────────────────────────────────────────────────────
*
* Applying the repair writes one `AuditLog` row per account inside the
* same transaction as the update. An account that already carries that
* row is skipped, so a second run finds nothing to do — the multiplier
* can never be applied twice.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { auditLog } from "@/lib/auth/audit";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import { validateMeasurementRange } from "@/lib/validations/measurement";
import { dailyStatsExternalId } from "./apple-health-mapping";
import { convertHkValue } from "./hk-units";
/** The audit action that marks an account's distances as repaired. */
export const APPLE_HEALTH_DISTANCE_REPAIR_ACTION =
"measurement.apple_health_distance.repaired";
/** Minted by the same helper the export fold uses, so the two cannot drift. */
export const EXPORT_DISTANCE_EXTERNAL_ID_PREFIX = dailyStatsExternalId(
"HKQuantityTypeIdentifierDistanceWalkingRunning",
"",
);
/**
* The unit the affected archive was written in. Apple writes `km` on a
* metric account and `mi` on an imperial one, and the stored row keeps no
* record of which — the importer discarded the attribute, which is the
* defect. The operator names it; `km` is the default because it is what
* the Health app writes for every metric locale.
*/
export type ArchiveDistanceUnit = "km" | "mi";
export interface RepairCandidateRow {
id: string;
measuredAt: Date;
value: number;
}
export interface AccountRepairPlan {
userId: string;
/** Candidate rows that would be multiplied. */
repairable: RepairCandidateRow[];
/**
* Candidates whose repaired value would leave the plausibility range
* (`validateMeasurementRange`). One row here refuses the whole account:
* it is evidence the rows are NOT the 1000x class.
*/
outOfRange: RepairCandidateRow[];
/** Non-null when this account already carries the repair's audit row. */
alreadyRepairedAt: Date | null;
/**
* True when a previous run wrote the rows but its rollup tail did not
* finish — the audit row still says `rollupsRefreshed: false`, so the
* DAY/WEEK/MONTH/YEAR buckets and the cached status insights still hold
* the pre-repair numbers. Meaningless unless `alreadyRepairedAt` is set.
*/
rollupsPending: boolean;
/** Candidate rows found, whatever the disposition above. */
candidateCount: number;
}
/** The DB surface this module needs — the script hands it the real client. */
export type RepairClient = Pick<
PrismaClient,
"measurement" | "auditLog" | "$transaction"
>;
const REPAIRED_TYPE = "WALKING_RUNNING_DISTANCE" as const;
/** metres per unit of the archive's own length unit. */
export function repairFactorFor(unit: ArchiveDistanceUnit): number {
const factor = convertHkValue(1, unit, "m");
// The two archive units are length units by construction; the shared
// table is the single source of the factor either way.
if (factor === null) throw new Error(`no metre factor for unit "${unit}"`);
return factor;
}
/**
* List the repair candidates per account. Read-only — this is what the
* script's dry run prints.
*/
export async function planAppleHealthDistanceRepair(
client: RepairClient,
options: { archiveUnit: ArchiveDistanceUnit },
): Promise<AccountRepairPlan[]> {
const factor = repairFactorFor(options.archiveUnit);
const rows = await client.measurement.findMany({
where: {
type: REPAIRED_TYPE,
source: "APPLE_HEALTH",
aggregationProvenance: "EXPORT_XML_SOURCE_MAX",
externalId: { startsWith: EXPORT_DISTANCE_EXTERNAL_ID_PREFIX },
deletedAt: null,
},
select: { id: true, userId: true, value: true, measuredAt: true },
orderBy: [{ userId: "asc" }, { measuredAt: "asc" }],
});
if (rows.length === 0) return [];
const userIds = Array.from(new Set(rows.map((row) => row.userId)));
const repaired = await client.auditLog.findMany({
where: {
action: APPLE_HEALTH_DISTANCE_REPAIR_ACTION,
userId: { in: userIds },
},
select: { userId: true, createdAt: true, details: true },
orderBy: { createdAt: "asc" },
});
const repairedAt = new Map<string, Date>();
const rollupsPending = new Set<string>();
for (const entry of repaired) {
if (!entry.userId) continue;
repairedAt.set(entry.userId, entry.createdAt);
if (!rollupsRefreshedIn(entry.details)) rollupsPending.add(entry.userId);
}
const plans = new Map<string, AccountRepairPlan>();
for (const row of rows) {
let plan = plans.get(row.userId);
if (!plan) {
plan = {
userId: row.userId,
repairable: [],
outOfRange: [],
alreadyRepairedAt: repairedAt.get(row.userId) ?? null,
rollupsPending: rollupsPending.has(row.userId),
candidateCount: 0,
};
plans.set(row.userId, plan);
}
plan.candidateCount += 1;
if (plan.alreadyRepairedAt) continue;
const candidate: RepairCandidateRow = {
id: row.id,
measuredAt: row.measuredAt,
value: row.value,
};
if (validateMeasurementRange(REPAIRED_TYPE, row.value * factor) !== null) {
plan.outOfRange.push(candidate);
} else {
plan.repairable.push(candidate);
}
}
return Array.from(plans.values());
}
/**
* The command that heals the rollup tail when it did not run.
*
* Named here rather than in the script so the message an operator reads is
* the same one the module can be asked for.
*/
export function rollupRepairCommand(userId: string): string {
return `pnpm dlx tsx scripts/backfill-rollups.ts --user ${userId}`;
}
/** Did a repair audit row record a finished rollup tail? */
function rollupsRefreshedIn(details: string | null): boolean {
if (!details) return false;
try {
const parsed: unknown = JSON.parse(details);
return (
typeof parsed === "object" &&
parsed !== null &&
(parsed as { rollupsRefreshed?: unknown }).rollupsRefreshed === true
);
} catch {
return false;
}
}
export interface RepairOutcome {
userId: string;
updated: number;
skipped: number;
/**
* Why the account was left alone, when it was. Null on a run that wrote.
* The script prints it verbatim.
*/
refusedReason: string | null;
/** The out-of-range candidates — what a refusal was decided on. */
skippedRows: RepairCandidateRow[];
/**
* True when the rows were written but the rollup tail was not. The audit
* row still reads `rollupsRefreshed: false`, so a later run can tell this
* apart from a finished repair; the caller must surface
* `rollupRepairCommand(userId)` and exit non-zero.
*/
rollupsPending: boolean;
/** What the rollup tail threw, when it threw. */
rollupError: unknown;
}
/**
* The refusal an out-of-range candidate earns.
*
* The provenance criterion proves an archive origin; it cannot prove the
* archive was imported on the BROKEN build. An account that has since been
* re-imported on the fixed build carries the same stamp with the right
* numbers, and its rest days — under 200 m, phone left at home — pass the
* plausibility check after another x1000 and would be written. So a single
* row that would leave the range is treated as what it is: evidence the
* account is not the 1000x class. The whole account stops.
*/
function outOfRangeRefusal(plan: AccountRepairPlan, factor: number): string {
return (
`${plan.outOfRange.length} of ${plan.candidateCount} candidate row(s) ` +
`leave the plausible range after x${factor}, so these rows are not the ` +
"1000x class — the account was most likely re-imported on the fixed " +
"build, or its archive was never in the affected unit. Nothing written. " +
"There is no override: check the dry run's worked examples against the " +
"account's Health app, and if the archive really was in another unit, " +
"re-run with the matching --unit."
);
}
/**
* Apply one account's plan: every repairable row multiplied by the
* archive unit's metre factor in ONE transaction, together with the audit
* row that makes a second run a no-op. Returns what it did.
*/
export async function applyAppleHealthDistanceRepair(
client: RepairClient,
plan: AccountRepairPlan,
options: { archiveUnit: ArchiveDistanceUnit },
): Promise<RepairOutcome> {
const factor = repairFactorFor(options.archiveUnit);
const skipped = plan.outOfRange.length;
const base = {
userId: plan.userId,
updated: 0,
skipped,
skippedRows: plan.outOfRange,
rollupsPending: false,
rollupError: null,
};
if (plan.alreadyRepairedAt) {
return {
...base,
refusedReason: `already repaired on ${plan.alreadyRepairedAt.toISOString()}`,
};
}
// Refuse the ACCOUNT, not just the row: see `outOfRangeRefusal`.
if (plan.outOfRange.length > 0) {
return { ...base, refusedReason: outOfRangeRefusal(plan, factor) };
}
if (plan.repairable.length === 0) {
return { ...base, refusedReason: "no repairable rows" };
}
const ids = plan.repairable.map((row) => row.id);
const details = {
type: REPAIRED_TYPE,
archiveUnit: options.archiveUnit,
factor,
rows: ids.length,
skippedOutOfRange: skipped,
issue: 944,
// Flipped to true only once the rollup tail below has finished. A row
// left at false says "rows repaired, rollups still pre-repair", which is
// what a re-run has to be able to see: the account is skipped from here
// on, so nothing else would ever notice.
rollupsRefreshed: false,
};
const auditId = await client.$transaction(async (tx): Promise<string> => {
// One statement per chunk rather than one per row: an account with a
// decade of history carries thousands of rows and an interactive
// transaction has a clock on it. `sync_version` + `updated_at` are
// bumped by hand because raw SQL bypasses Prisma's `@updatedAt`, and
// the iOS delta feed pages on exactly those two columns.
for (let i = 0; i < ids.length; i += 500) {
const chunk = ids.slice(i, i + 500);
await tx.$executeRaw`
UPDATE measurements
SET value = value * ${factor},
unit = 'm',
sync_version = sync_version + 1,
updated_at = NOW()
WHERE id = ANY(${chunk})`;
}
await auditLog(APPLE_HEALTH_DISTANCE_REPAIR_ACTION, {
client: tx,
userId: plan.userId,
actorUserId: null,
details,
});
// Read the row back rather than widening `auditLog`'s return type for
// ~600 call sites. Inside the same transaction, and an account can hold
// at most one of these rows — the second run is refused above — so the
// lookup is exact, not a guess at the newest.
const entry = await tx.auditLog.findFirstOrThrow({
where: {
action: APPLE_HEALTH_DISTANCE_REPAIR_ACTION,
userId: plan.userId,
},
select: { id: true },
});
return entry.id;
});
// The tail: the DAY/WEEK/MONTH/YEAR rollups and the cached status insights
// hold the pre-repair numbers until they are recomputed. It runs outside
// the transaction, so a throw here leaves the rows repaired and the derived
// tier stale — and the account is skipped from the next run on. Hence the
// flag: it is flipped only after the tail returned, and the caller is told
// to run the backfill and to exit non-zero.
let rollupsPending = false;
let rollupError: unknown = null;
try {
await afterMeasurementMutation(
plan.userId,
plan.repairable.map((row) => ({
type: REPAIRED_TYPE,
measuredAt: row.measuredAt,
})),
"repair-apple-health-distance",
);
await client.auditLog.update({
where: { id: auditId },
data: { details: JSON.stringify({ ...details, rollupsRefreshed: true }) },
});
} catch (err) {
// Either leg failing leaves the flag at false, which is the safe
// direction: an operator re-running the backfill needlessly costs time,
// one who never learns it is due keeps wrong numbers on every chart.
rollupsPending = true;
rollupError = err;
}
return {
userId: plan.userId,
updated: ids.length,
skipped,
refusedReason: null,
skippedRows: plan.outOfRange,
rollupsPending,
rollupError,
};
}
/** Narrowing helper for the script's `--unit` flag. */
export function parseArchiveUnit(raw: string): ArchiveDistanceUnit | null {
return raw === "km" || raw === "mi" ? raw : null;
}
@@ -630,6 +630,49 @@ describe("sendViaApns — dispatcher fan-out", () => {
expect(note.payload.eventType).toBe("MEDICATION_REMINDER");
});
// The iOS Notification Service Extension that replaces the medication name
// on the lock screen runs only when the payload carries
// `aps.mutable-content = 1`. The category test above happens to touch the
// flag; this pins it on its own, because losing it would not fail a
// category assertion and the name would silently reappear on the lock
// screen of a device that asked for it to be hidden.
it("marks a MEDICATION_REMINDER mutable so the client extension can rewrite the alert", async () => {
vi.mocked(prisma.device.findMany).mockResolvedValueOnce([
{ id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" },
] as never);
sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] });
await sendViaApns("u-1", {
title: "t",
message: "m",
eventType: "MEDICATION_REMINDER",
metadata: { medicationId: "med-1" },
});
expect(sendMock.mock.calls[0][0].mutableContent).toBe(true);
});
// Not a medication-only flag: `sendViaApns` sets it on every alert it
// builds, so an extension may enrich whichever event types the client
// registers for. Recorded as an assertion rather than left to be assumed
// from the source, because narrowing it to one branch would take a
// capability away from the client and should read as the contract change it
// is, not as a tidy-up.
it("marks every alert event type mutable, not only the medication branch", async () => {
vi.mocked(prisma.device.findMany).mockResolvedValueOnce([
{ id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" },
] as never);
sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] });
await sendViaApns("u-1", {
title: "t",
message: "m",
eventType: "MEASUREMENT_REMINDER",
});
expect(sendMock.mock.calls[0][0].mutableContent).toBe(true);
});
it("omits action metadata and categories for a managed Guardian", async () => {
vi.mocked(prisma.device.findMany).mockResolvedValueOnce([
{ id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" },
+1 -1
View File
@@ -507,7 +507,7 @@ const analyticsDefaultResponse = z
.record(z.string(), dataSummary)
.nullable()
.describe(
"Per-context glucose summaries (FASTING / POSTPRANDIAL / RANDOM / BEDTIME) over the trailing 30 days, canonical mg/dL. Contexts with no readings are omitted; the whole block is null when the glucose module is off.",
"Per-context glucose summaries (FASTING / POSTPRANDIAL / RANDOM / BEDTIME, plus UNSPECIFIED for readings whose source recorded no meal-time context) over the trailing 30 days, canonical mg/dL. Buckets with no readings are omitted; the whole block is null when the glucose module is off.",
),
glucoseClinical: glucoseClinicalSchema
.nullable()
+1 -1
View File
@@ -2178,7 +2178,7 @@ export const analyticsDefaultSliceResponse = z
.record(z.string(), dataSummary)
.nullable()
.describe(
"Per-context summaries (`FASTING` / `POSTPRANDIAL` / `RANDOM` / `BEDTIME`) over the trailing 30 days, canonical mg/dL. A context with no readings is omitted. NULL — not an empty object — when the glucose module is off.",
"Per-context summaries (`FASTING` / `POSTPRANDIAL` / `RANDOM` / `BEDTIME`, plus `UNSPECIFIED` for readings whose source recorded no meal-time context) over the trailing 30 days, canonical mg/dL. A bucket with no readings is omitted. NULL — not an empty object — when the glucose module is off.",
),
glucoseClinical: glucoseClinicalSchema
.nullable()
+53
View File
@@ -38,6 +38,7 @@ import { ouraCredentialsSchema } from "@/lib/validations/oura";
import { polarCredentialsSchema } from "@/lib/validations/polar";
import { stravaCredentialsSchema } from "@/lib/validations/strava";
import { whoopCredentialsSchema } from "@/lib/validations/whoop";
import { nightscoutConnectSchema } from "@/lib/validations/nightscout";
import { withingsCredentialsSchema } from "@/lib/validations/withings";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
@@ -330,6 +331,12 @@ const integrationLedgerState = z
"Last recorded ledger state for this provider. `connected` is also the value a user who has never synced sees — the ledger row is created on the first attempt, and its absence reads as `connected`, not as an error.",
);
const nightscoutConnectRequest = nightscoutConnectSchema.meta({
id: "NightscoutConnectRequest",
description:
"Instance URL and optional API token. Both are trimmed before use — a trailing space or newline from a copy button reaches the instance verbatim and answers as an opaque DNS failure. `token` may be omitted or empty for a fully public instance (`AUTH_DEFAULT_ROLES=readable`). `allowPrivateHost` is accepted for older clients and ignored: only the server-side `NIGHTSCOUT_PRIVATE_ORIGINS` allowlist can authorise a private origin.",
});
const nightscoutStatusResponse = z
.object({
connected: z
@@ -1101,6 +1108,52 @@ export const integrationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/nightscout/connect": {
post: {
tags: ["Integrations"],
summary: "Connect (or update) the caller's Nightscout instance",
description:
"Validates the URL and token with a live single-entry probe BEFORE storing anything, so a wrong token, an unreachable instance or an unapproved private origin surfaces here rather than on the first cron tick. On success the URL and token are encrypted at rest, the `nightscout` ledger is reset to connected, and a first sync is enqueued best-effort. Re-sending replaces the stored values; there is no partial update. Body capped at 16 KiB, rate-limited 10 requests / 60 s per user. Auth via cookie or Bearer.",
requestBody: {
required: true,
content: {
"application/json": { schema: nightscoutConnectRequest },
},
},
responses: {
"200": {
description: "The instance answered the probe and was stored.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ connected: z.literal(true) }),
"NightscoutConnectEnvelope",
),
},
},
},
"400": {
description: "Body is not parseable JSON.",
content: { "application/json": { schema: errorEnvelope } },
},
"413": {
description: "Body exceeds 16 KiB.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
"422": {
description:
"The URL failed validation, the origin is private and the operator has not approved it (`meta.errorCode` = `private_origin_not_approved` or `invalid_origin`), Nightscout rejected the token, or the instance could not be reached. Nothing was written. The message is fixed per class and never the upstream error's own: a Nightscout base URL carries its API token as a query parameter.",
content: { "application/json": { schema: errorEnvelope } },
},
"429": {
description:
"More than 10 connection attempts in 60 s from this user.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
"/api/nightscout/status": {
get: {
tags: ["Integrations"],
+7 -1
View File
@@ -53,7 +53,13 @@ const batchEntrySchema = z
.describe(
"v1.19.2 (iOS #34 extension) — the bucket's MAXIMUM bpm for a 10-minute heart-rate bucket (see `externalId`). Persisted ONLY on a well-formed `stats:HKQuantityTypeIdentifierHeartRate:<bucket-start>` row; ignored (stored null) on every other entry. Omit on a pre-v1.19.2 client — the bucket keeps the avg-only contract.",
),
unit: z.string().min(1).max(60),
unit: z
.string()
.min(1)
.max(60)
.describe(
"Apple's `HKUnit` string for `value`, captured for audit: the server records it and never validates `value` against it. `value` is read in Apple's own `HKUnit` for the identifier — `m` for a length, `kg` for body mass, `kcal` for energy, `degC` for temperature, a `fraction` for the percent-shaped ones (oxygen saturation, body fat) — and HealthLog applies its storage scaling after that. A waist circumference sent as `0.86` in `m` is stored as `86` `cm`; an oxygen saturation sent as `0.98` in `fraction` is stored as `98` `%`. Send the sample in Apple's unit, not in HealthLog's.",
),
startDate: z.iso.datetime({ offset: true }),
endDate: z.iso.datetime({ offset: true }),
sleepStage: z
@@ -575,6 +575,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
post: {
parameters: [idempotencyKeyParameter],
tags: ["Medications"],
summary: "Log a side effect",
description:
@@ -585,6 +586,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: createSideEffectRequest } },
},
responses: {
...idempotentWrite(),
...recordRefusal(),
"201": {
description: "The created entry, with its note decrypted.",
@@ -912,6 +914,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
post: {
parameters: [idempotencyKeyParameter],
tags: ["Medications"],
summary: "Create a medication with at least one schedule",
description:
@@ -921,6 +924,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: createMedicationSchema } },
},
responses: {
...idempotentWrite(),
...recordRefusal(),
"201": {
description: "Created medication with its schedules.",
@@ -1220,6 +1224,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
post: {
parameters: [idempotencyKeyParameter],
tags: ["Medications"],
summary: "Register a new supply container",
description:
@@ -1234,6 +1239,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
responses: {
...idempotentWrite(),
...recordRefusal(),
"201": {
description: "Created inventory item.",
+50
View File
@@ -60,3 +60,53 @@ export function describeRejectedProfileField(
const labelKey = labelKeys[first.path];
return labelKey ? t(labelKey) : first.path;
}
/**
* The sentence a person reads under the input, keyed by the validator
* code the server sent back. The code is the only reason that crosses
* the wire — `message` is the validator's own prose and is deliberately
* never shown — so each code gets one plain sentence saying what was
* wrong with the value, in the person's language.
*
* A code with no entry here falls back to `other`, which says the field
* was not accepted without inventing a reason. New Zod codes therefore
* degrade to something honest instead of to silence.
*/
const REJECTION_REASON_KEYS: Record<string, string> = {
too_big: "settings.profileRejection.tooBig",
too_small: "settings.profileRejection.tooSmall",
invalid_value: "settings.profileRejection.invalidValue",
invalid_format: "settings.profileRejection.invalidFormat",
invalid_type: "settings.profileRejection.invalidType",
};
const REJECTION_FALLBACK_KEY = "settings.profileRejection.other";
/**
* Turn a rejection list into one sentence per field, keyed by the
* schema path so a form can drop each one into the slot under the
* input it belongs to.
*
* Every refused field is carried, not just the first: a submission with
* two bad values that only names one sends the person round the loop
* twice. Where a field is refused for several reasons at once the first
* one wins — the others are restatements of the same wrong value.
*
* No label map is needed: the sentence sits under the input, which
* already carries the label, and the key is the schema path so the
* calling screen can place it without a rename. A path the screen has
* no input for still gets an entry rather than being dropped, so the
* caller can decide what to do with it.
*/
export function describeRejectedProfileFields(
fields: RejectedProfileField[] | undefined,
t: (key: string, params?: Record<string, string | number>) => string,
): Record<string, string> {
const out: Record<string, string> = {};
for (const field of fields ?? []) {
if (out[field.path]) continue;
const key = REJECTION_REASON_KEYS[field.code] ?? REJECTION_FALLBACK_KEY;
out[field.path] = t(key);
}
return out;
}
+24 -18
View File
@@ -48,6 +48,7 @@
import { prisma } from "@/lib/db";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { annotate } from "@/lib/logging/context";
import { trackBackgroundTask } from "@/lib/logging/background-tasks";
import { startOfUtcDay } from "@/lib/tz/start-of-utc-day";
import type {
Prisma,
@@ -307,25 +308,30 @@ export async function recomputeMoodBucketsForEntry(
// off the pg-boss critical path. Errors stay caught inside
// `enqueueMoodRollupRecompute` so the unhandled-rejection logger
// captures any pg-boss failure.
void Promise.all(
ASYNC_GRANULARITIES.map((granularity) => {
const { from, to } = bucketSpan(dateLabel, granularity);
return enqueueMoodRollupRecompute({
userId,
granularity,
from,
to,
}).catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
annotate({
meta: {
mood_rollup_enqueue_failed: true,
mood_rollup_enqueue_error: message,
},
// Registered so the test suite can await it: the error below is a console
// write on a detached handle, and one that lands while a vitest worker is
// closing its RPC channel fails the whole run. No-op outside test.
trackBackgroundTask(
Promise.all(
ASYNC_GRANULARITIES.map((granularity) => {
const { from, to } = bucketSpan(dateLabel, granularity);
return enqueueMoodRollupRecompute({
userId,
granularity,
from,
to,
}).catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
annotate({
meta: {
mood_rollup_enqueue_failed: true,
mood_rollup_enqueue_error: message,
},
});
console.error("[mood-rollups] async enqueue failed:", message);
});
console.error("[mood-rollups] async enqueue failed:", message);
});
}),
}),
),
);
}
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff. Show more