Merge pull request #1264 from MBombeck/release/v1.45.0

Release v1.45.0
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-23 03:26:55 +02:00
commit cf61bd1c85
795 files changed
+116601 -30374

No files matched your search

+6
View File
@@ -28,6 +28,12 @@ playwright/.cache
playwright.config.ts
vitest.config.mts
vitest.integration.config.mts
vitest.mutation.config.mts
# The unit setup imports tests/ (excluded above); the image never runs it and
# the build's type check must not see a half-present test harness.
vitest.setup.ts
stryker.config.mjs
stryker.floors.json
tests
tests/
e2e
+4 -3
View File
@@ -1,16 +1,16 @@
name: Translation feedback
description: Report a wording, grammar, or register problem in HealthLog's AI-initial locales (FR / ES / IT / PL / KO / NL).
description: Report a wording, grammar, or register problem in HealthLog's community-maintained locales (FR / ES / IT / PL / KO / NL / PT-BR).
title: "[Translation] "
labels: ["i18n", "translation-feedback"]
body:
- type: markdown
attributes:
value: |
HealthLog ships German and English as maintainer-curated translations. French, Spanish, Italian, Polish, Korean, and Dutch ship as **AI-initial translations** — the prose passes the locale-integrity test (full key parity, no empty values) but has not been hand-reviewed for register or regional vocabulary.
HealthLog ships German and English as maintainer-curated translations. French, Spanish, Italian, Polish, Korean, Dutch, and Brazilian Portuguese ship as **AI-initial, community-maintained translations** — the prose passes the locale-integrity test (full key parity, no empty values) but has not been hand-reviewed for register or regional vocabulary.
This template captures concrete fixes you would like to see. Phrasing nuance, regional vocabulary (e.g. "Tú" vs "Usted"), gendered forms, and domain-specific health terms are all in scope. The maintainer applies fixes during the regular release cycle.
For wholly new locale requests (e.g. "please add Portuguese"), open a [Discussion](https://github.com/MBombeck/HealthLog/discussions) instead.
For wholly new locale requests (e.g. "please add Swedish"), open a [Discussion](https://github.com/MBombeck/HealthLog/discussions) instead.
- type: dropdown
id: locale
attributes:
@@ -22,6 +22,7 @@ body:
- Polish (pl)
- Korean (ko)
- Dutch (nl)
- Brazilian Portuguese (pt-BR)
- Other (specify under "Surface")
validations:
required: true
+6 -5
View File
@@ -95,11 +95,12 @@ jobs:
- run: NODE_OPTIONS=--max-old-space-size=8192 pnpm build
# Client-bundle budget gate — rides this job because it is the one
# place CI already has a production build on disk. Catches the
# regressions the dead webpack-era tooling (ANALYZE=1, the old
# bundle-report) let through: a statically imported message catalog
# (~100 KB gz on every route) or a chart import bypassing the shared
# chart-runtime boundary (a duplicated ~92 KB gz recharts chunk).
# place CI already has a production build on disk. Measures the first
# load of every page (files deduplicated by content hash) against its
# baseline, the p95 and max across pages, and the distinct client code
# (modules deduplicated by id); see the script header. Catches a
# statically imported message catalog, a duplicated recharts chunk, or
# a heavy module (Zod) pulled into a page that had none.
- run: pnpm bundle-check
- run: pnpm exec playwright test --shard=${{ matrix.shard }}/3
+30 -13
View File
@@ -10,20 +10,23 @@ permissions:
contents: read
jobs:
# The suite outgrew a single runner, then a half-suite outgrew a slow one,
# and then a third of it did too. Three shards of about 68 files cleared in
# 13 minutes on an ordinary runner, but a slow runner day ran every test at
# twice its usual duration and the first shard was still working through
# its list when the 25-minute guard cut it off: three of four pushes to main
# between 2026-08-31 and 2026-09-01 failed that way, with green tests logged
# up to the last second. Four shards keep each slice around 50 files, and
# the guard sits at three times the ordinary run rather than under two. A
# wedged test still surfaces through vitest's own 60-second budget, so the
# job timeout only needs to catch a container that never boots. Each shard
# boots its own Postgres testcontainer.
# Four shards of about equal duration, each running its files in parallel
# against one database per worker. The shards used to be four slices of
# equal file count, run serially: 954 s to 1 449 s, because a slice holding
# the sharing matrix and the backup files took half again as long as one
# holding small files. `--shard` now goes through the duration sequencer
# (`tests/durations/sequencer.ts`, timings in
# `tests/durations/integration.json`), and every file in a shard runs on
# one of three workers, each against its own clone of the migrated template
# (`tests/integration/worker-database.ts`). Three workers leave a core of
# the four-core runner to the Postgres container. The job guard stays well
# above an ordinary run; a wedged test still surfaces through vitest's own
# 60-second budget, so the guard only needs to catch a container that never
# boots. Each shard boots its own Postgres testcontainer and uploads its
# JSON report, which `scripts/test-durations.mjs` turns into the timing file.
shard:
runs-on: ubuntu-24.04
timeout-minutes: 40
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
@@ -48,8 +51,13 @@ jobs:
# vitest a stray positional argument and the shard flag never applies —
# both "shards" then run the full suite. Proven by a paired local run
# before this workflow shipped.
- run: pnpm exec vitest run --config vitest.integration.config.mts --shard=${{ matrix.shard }}/4
- run: >-
pnpm exec vitest run --config vitest.integration.config.mts
--shard=${{ matrix.shard }}/4
--reporter=default --reporter=json
--outputFile.json=integration-report-${{ matrix.shard }}.json
env:
HEALTHLOG_INTEGRATION_WORKERS: "3"
# The integration suite boots a Postgres testcontainer and points
# Prisma at it via DATABASE_URL set inside `tests/integration/setup.ts`.
# These env vars satisfy code paths that read them at module load.
@@ -63,6 +71,15 @@ jobs:
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000"
SESSION_SECRET: "integration-tests"
- name: Upload the timing report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: integration-report-${{ matrix.shard }}
path: integration-report-${{ matrix.shard }}.json
if-no-files-found: ignore
retention-days: 14
# Branch protection requires a check named exactly "integration". This job
# carries that name and answers for every shard: it fails when any shard
# failed or was cancelled, so a half-green matrix cannot satisfy the gate.
+53
View File
@@ -0,0 +1,53 @@
name: Mutation tests
# Stryker on the core libraries (stryker.config.mjs): encryption at rest,
# medication compliance, rollups, source ranking, unit conversion and backup
# validation. Too slow for a pull request; it runs once a night and on
# demand, and fails when a module's score drops below its floor in
# stryker.floors.json. The HTML report is uploaded either way.
on:
schedule:
- cron: "23 1 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
mutation:
runs-on: ubuntu-24.04
timeout-minutes: 120
name: mutation (core libraries)
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
- name: Run Stryker
run: pnpm exec stryker run --concurrency 4
# Without pipefail the step takes the exit status of `tee`, which is
# always 0, so a module under its floor could never fail the run.
- name: Check the per-module floors
shell: bash
run: |
set -o pipefail
node scripts/mutation-floors.mjs | tee -a "$GITHUB_STEP_SUMMARY"
- name: Upload the report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: mutation-report
path: reports/mutation/
if-no-files-found: ignore
retention-days: 30
+94 -30
View File
@@ -15,11 +15,72 @@ permissions:
contents: read
jobs:
quality:
name: Lint, Typecheck & Test
# The static checks: everything in the old single quality job except the
# unit suite, which runs sharded below.
static:
name: Lint, Typecheck & OpenAPI
runs-on: ubuntu-24.04
if: github.event_name != 'schedule'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
# Blocking since v1.15.20 — the settings-split refactor that kept
# this warn-only is long done and the tree lints clean.
- name: ESLint
run: pnpm lint
# Prettier formatting gate. The format pass excludes generator-owned
# artifacts via .prettierignore (notably docs/api/openapi.yaml, which
# the OpenAPI drift check below enforces byte-for-byte).
- name: Prettier format check
run: pnpm format:check
- name: TypeScript strict check
run: pnpm typecheck
# OpenAPI spec drift gate — hard-fail (flipped from warn-only in v1.4.25).
#
# The Zod registry is the source of truth for the public API contract.
# Any route signature change must be reflected in `docs/api/openapi.yaml`
# so downstream consumers (notably the v1.5 iOS Swift codegen) stay in
# lockstep. If this step fails, run `pnpm openapi:generate` locally and
# commit the regenerated spec.
- name: OpenAPI spec drift check
run: |
pnpm openapi:generate
if ! git diff --exit-code docs/api/openapi.yaml; then
echo "::error::OpenAPI drift detected — regenerate the spec via 'pnpm openapi:generate' and commit the result. The Zod registry under src/lib/openapi/ is the source of truth for the public API contract."
exit 1
fi
echo "::notice::OpenAPI spec in sync with Zod source schemas."
# The unit suite in three slices of about equal duration. `--shard` goes
# through the duration sequencer in `vitest.config.mts`, which deals files
# out by their measured time (`tests/durations/unit.json`) instead of by
# count. Each slice uploads its JSON report, which is what
# `scripts/test-durations.mjs` regenerates the timing file from.
unit:
runs-on: ubuntu-24.04
if: github.event_name != 'schedule'
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
name: unit shard ${{ matrix.shard }}/3
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -38,7 +99,8 @@ jobs:
# real browser and measures the boxes. Without a browser here it caught
# its own launch failure and passed anyway, so from its first day until
# v1.38 it measured nothing on any run of this job. The browser is part
# of the gate now; the test refuses to skip when CI is set.
# of the gate now; the test refuses to skip when CI is set. Every slice
# installs it, because the sequencer decides which slice the file lands in.
#
# Headless shell only: `chromium.launch()` resolves to
# chromium_headless_shell, which is what the failing runs named, and the
@@ -54,38 +116,40 @@ jobs:
- run: pnpm exec playwright install --only-shell chromium
# Blocking since v1.15.20 — the settings-split refactor that kept
# this warn-only is long done and the tree lints clean.
- name: ESLint
run: pnpm lint
# Prettier formatting gate. The format pass excludes generator-owned
# artifacts via .prettierignore (notably docs/api/openapi.yaml, which
# the OpenAPI drift check below enforces byte-for-byte).
- name: Prettier format check
run: pnpm format:check
- name: TypeScript strict check
run: pnpm typecheck
# vitest directly, not `pnpm test --`: pnpm hands the separator through
# literally and the shard flag would never reach vitest.
- name: Unit tests
run: pnpm test
run: >-
pnpm exec vitest run --shard=${{ matrix.shard }}/3
--reporter=default --reporter=json
--outputFile.json=unit-report-${{ matrix.shard }}.json
# OpenAPI spec drift gate — hard-fail (flipped from warn-only in v1.4.25).
#
# The Zod registry is the source of truth for the public API contract.
# Any route signature change must be reflected in `docs/api/openapi.yaml`
# so downstream consumers (notably the v1.5 iOS Swift codegen) stay in
# lockstep. If this step fails, run `pnpm openapi:generate` locally and
# commit the regenerated spec.
- name: OpenAPI spec drift check
- name: Upload the timing report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: unit-report-${{ matrix.shard }}
path: unit-report-${{ matrix.shard }}.json
if-no-files-found: ignore
retention-days: 14
# Branch protection requires a check named exactly "Lint, Typecheck & Test".
# That used to be one job running everything in turn; it is now this one,
# which answers for the static checks and every unit slice, so a half-green
# set cannot satisfy the gate.
quality:
name: Lint, Typecheck & Test
runs-on: ubuntu-24.04
needs: [static, unit]
if: always() && github.event_name != 'schedule'
timeout-minutes: 5
steps:
- name: The static checks and every unit slice finished green
run: |
pnpm openapi:generate
if ! git diff --exit-code docs/api/openapi.yaml; then
echo "::error::OpenAPI drift detected — regenerate the spec via 'pnpm openapi:generate' and commit the result. The Zod registry under src/lib/openapi/ is the source of truth for the public API contract."
if [ "${{ needs.static.result }}" != "success" ] || [ "${{ needs.unit.result }}" != "success" ]; then
echo "static: ${{ needs.static.result }}, unit: ${{ needs.unit.result }}"
exit 1
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
+178
View File
@@ -0,0 +1,178 @@
name: Tests (nightly, shuffled)
# Looks for what a pull request run cannot see by design.
#
# - Order dependence. Both suites run with files and tests in a random order
# (`--sequence.shuffle`), seeded from the clock and printed at the top of
# the log and in the job summary. A failure that does not reproduce on a
# pull request reproduces here with `--sequence.seed=<seed>`.
# - New property-test inputs. Pull requests run every fast-check property
# with a fixed seed; this run picks a fresh one (`FC_SEED`) and five times
# the cases, and fast-check prints the seed and the shrunk counterexample
# on a failure (see tests/property/fast-check.ts).
# - Stale shard timings. The integration reports of this run are merged into
# a regenerated `tests/durations/integration.json`, uploaded as an
# artifact for a maintainer to commit when the shards drift apart.
#
# Not a gate: nothing here blocks a merge. A red run is an issue to open.
on:
schedule:
- cron: "41 2 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
unit:
runs-on: ubuntu-24.04
timeout-minutes: 45
name: unit (shuffled)
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
- 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
- name: Pick the seeds
run: |
echo "TEST_SEED=$(date +%s)" >> "$GITHUB_ENV"
echo "FC_SEED=$(( $(date +%s%N) % 2147483647 ))" >> "$GITHUB_ENV"
- name: Unit tests, shuffled
shell: bash
env:
# Five times each property's own case count (tests/property/fast-check.ts).
FC_RUNS_FACTOR: "5"
run: |
set -o pipefail
echo "Order seed ${TEST_SEED}, property seed ${FC_SEED}" | tee -a "$GITHUB_STEP_SUMMARY"
pnpm exec vitest run --sequence.shuffle --sequence.seed="${TEST_SEED}" \
--reporter=default --reporter=json --outputFile.json=unit-report.json
- name: Upload the timing report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: nightly-unit-report
path: unit-report.json
if-no-files-found: ignore
retention-days: 14
integration:
runs-on: ubuntu-24.04
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
name: integration shard ${{ matrix.shard }}/4 (shuffled)
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
# One seed per run, shared by the shards through the run id, so every
# shard shuffles the same way and a failure names one seed to replay.
- name: Pick the seeds
run: |
echo "TEST_SEED=${{ github.run_id }}" >> "$GITHUB_ENV"
echo "FC_SEED=$(( ${{ github.run_id }} % 2147483647 ))" >> "$GITHUB_ENV"
- name: Integration tests, shuffled
shell: bash
env:
HEALTHLOG_INTEGRATION_WORKERS: "3"
# Four times each property's own case count; a property's timeout
# grows by the same factor (`propertyTimeout`).
FC_RUNS_FACTOR: "4"
# Quoted for the reason given in integration.yml.
API_TOKEN_HMAC_KEY: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000"
SESSION_SECRET: "integration-tests"
run: |
set -o pipefail
echo "Order seed ${TEST_SEED}, property seed ${FC_SEED}" | tee -a "$GITHUB_STEP_SUMMARY"
pnpm exec vitest run --config vitest.integration.config.mts \
--shard=${{ matrix.shard }}/4 \
--sequence.shuffle --sequence.seed="${TEST_SEED}" \
--reporter=default --reporter=json \
--outputFile.json=integration-report-${{ matrix.shard }}.json
- name: Upload the timing report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: nightly-integration-report-${{ matrix.shard }}
path: integration-report-${{ matrix.shard }}.json
if-no-files-found: ignore
retention-days: 14
# Merge the timings of this run into fresh timing files. Uploaded, not
# committed: the workflow has read-only access, and a maintainer decides
# whether the drift is worth a commit.
durations:
runs-on: ubuntu-24.04
needs: [unit, integration]
if: always()
timeout-minutes: 10
name: regenerate shard timings
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: nightly-*-report*
path: reports
- name: Regenerate
shell: bash
run: |
set -o pipefail
shopt -s nullglob
integration=(reports/nightly-integration-report-*/*.json)
unit=(reports/nightly-unit-report/*.json)
if [ ${#integration[@]} -gt 0 ]; then
node scripts/test-durations.mjs integration "${integration[@]}"
fi
if [ ${#unit[@]} -gt 0 ]; then
node scripts/test-durations.mjs unit "${unit[@]}"
fi
git diff --stat -- tests/durations | tee -a "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: test-durations
path: tests/durations/*.json
retention-days: 14
+3
View File
@@ -98,3 +98,6 @@ next-env.d.ts
# same reason in a different tool.
/.wt-*/
/.worktrees/
# Mutation testing (stryker.config.mjs): reports and the sandbox copy.
/reports/mutation
/.stryker-tmp
+3
View File
@@ -11,6 +11,9 @@ paths = [
# All test files contain hardcoded fake tokens / idempotency-keys.
'''(^|/)__tests__/''',
'''\.test\.ts$''',
# Golden copies of the backup file written by the parity test: synthetic
# fixture values, and every `…Encrypted` value already opened to its text.
'''^tests/integration/__golden__/''',
# OpenAPI spec contains example tokens (`hlk_example…`) used to document the schema.
'''^docs/api/openapi\.yaml$''',
]
+18
View File
@@ -16,3 +16,21 @@ docs/api/openapi.yaml
# Build-generated service worker (the PWA build emits it).
public/sw.js
# Ratchet baselines, written by the guards themselves (RATCHET_WRITE=1). The
# writer's JSON layout is the canonical one, so a write-back never fails the
# format check.
src/__tests__/baselines
# Benchmark results, written by scripts/bench/run.ts and committed as they
# come out, so a re-run diffs cleanly against the last one.
scripts/bench/results
# Golden copies of the backup file and the restored account, written by the
# parity test itself (BACKUP_PARITY_WRITE=1), so a rewrite diffs cleanly.
tests/integration/__golden__
# inlang writes its working files (cache, metadata, README) next to the
# project settings when its CLI or SDK runs; only settings.json is tracked.
project.inlang/*
!project.inlang/settings.json
+97
View File
@@ -1,5 +1,102 @@
# Changelog
## [1.45.0] — 2026-10-23
### Added
- **Brazilian Portuguese.** The whole app, the Coach and the reports are
available in pt-BR, with Brazil's crisis lines (CVV 188, SAMU 192) next
to the international ones. Portuguese readers outside Brazil see 112 and
Portugal's SNS 24 and SOS Voz Amiga as well.
- **Translate in the browser.** Translators can edit any language on the
web with their GitHub account and send the change as a pull request; the
contributing guide links the editor and explains what reviewers check.
- **Dengue and tuberculosis in the vaccine catalogue.** Dengue is listed
with the travel vaccines (two doses three months apart) and tuberculosis
with the childhood ones (one dose at birth); neither has a booster. Each
cites its WHO position paper, and searching "bcg" finds tuberculosis.
- **The Coach knows your whole lab history.** Asked about a marker such as
LDL or PSA, it now sees your recent values and a yearly summary going
back ten years, instead of stopping at twelve months. Other readings keep
the one-year window. (#1263)
### Changed
- **Faster.** On an account with ten years of data the dashboard loads in
well under half the time, a ten-year chart about five times faster and a
one-year chart about three times faster. The values are the same: the
new reads are checked against the old ones across time zones, daylight
saving changes and multiple sources.
- **Steadier start and less memory.** The app no longer freezes for most of
a second while it starts, holds only the language you use in memory, and
pauses heavy background work when the container gets close to its memory
limit instead of being restarted. Stopping the container lets running
jobs finish.
- **The questionnaires follow the validated originals.** PHQ-9, GAD-7,
WHO-5 and the sleep questionnaire now share one flow: the question's time
frame is always shown, answers are a proper choice list you can use with
the keyboard, and a review step lets you check every answer before it is
saved. The result explains the score in plain words and whether it moved
meaningfully since last time. The German, Spanish, Italian, Polish and
French wording now matches the published validated versions word for
word; where no validated translation exists, the original English is
shown rather than an unvalidated one.
- **Re-sent Apple Health data no longer counts as new.** When the iPhone
sends values the server already has, nothing is rewritten and the Apple
Health card no longer claims fresh data arrived; it says when data was
last re-sent without anything new. (#1259)
- Fonts are now part of the app instead of being fetched from Google when
an image is built.
### Fixed
- A workout synced by two services counts once everywhere, including when
the two copies fall on either side of a five-minute mark, and opening
either copy shows the same session.
- A backup restored for disaster recovery keeps the original creation
dates of medications, courses, ingredients, nutrient days and mood and
symptom links, and keeps sealed labels and notes sealed. Deleted family
history entries are part of that backup too.
- The Coach refuses more ways of telling you to change a dose, such as
halving it or taking "the same as before" when that is not your plan, in
every language. Several of its safety checks never matched words with
accents; they do now.
- A Strava connection whose refresh token expired now asks you to
reconnect instead of failing quietly.
- Ending a session from the session list records correctly whether it was
your own.
- Values that round to zero no longer show as "-0".
- Clearing a filter on the documents, measurements or mood pages, closing
a document or zooming the timeline no longer brings the old address back
a moment later.
- The nightly measurement maintenance no longer reports every run as
failed: it recorded the table's bloat as a decimal where only whole
numbers were accepted, although the maintenance itself had run. (#1271)
- Mood dimensions logged between local and UTC midnight count for today
again instead of going missing until the next day.
- Sheets on a phone open without a slide when your device asks for reduced
motion, and every control's accessible name now matches its visible
label.
- A large import from Apple Health, Health Connect, Withings or Oura
refreshes the charts that depend on it straight away.
### Upgrade notes
- Migration 0471 adds one column for Apple Health bookkeeping.
- Single-container installs need nothing else. If you run the web and
worker roles in separate containers, start the worker first: the job
queue updates its schema on start, and a web-only process waits for it.
See `docs/ops/deploy.md`.
- The supported memory floor stays at 1536 MB for the app container.
### Thanks
Thanks to @dareoon for the Brazilian Portuguese translation (#1251), to
@asmala for adding dengue and tuberculosis to the vaccine catalogue
(#1266) and for asking the Coach to look further back in the lab history
(#1263), and to @mills1975 for spotting the failing maintenance job
(#1271).
## [1.44.2] — 2026-10-22
### Changed
+27 -7
View File
@@ -121,7 +121,7 @@ Contributors: branch off `main`; PRs target `main`.
## Translations
HealthLog ships with English (`en`) and German (`de`) maintained by the project, plus French (`fr`), Spanish (`es`), Italian (`it`), Polish (`pl`), Korean (`ko`), and Dutch (`nl`) that are **AI-initial, community-maintained**. The maintained locales are reviewed line-by-line; the community-maintained ones surface a small banner inviting users to improve them.
HealthLog ships with English (`en`) and German (`de`) maintained by the project, plus French (`fr`), Spanish (`es`), Italian (`it`), Polish (`pl`), Korean (`ko`), Dutch (`nl`), and Brazilian Portuguese (`pt-BR`) that are **AI-initial, community-maintained**. The maintained locales are reviewed line-by-line; the community-maintained ones surface a small banner inviting users to improve them.
### Improving an existing locale
@@ -134,16 +134,36 @@ To suggest a fix or improvement:
3. Edit `messages/<locale>.json`. Keep the JSON valid; the i18n integrity test will flag missing or extra keys against `en.json` before CI accepts the PR.
4. Open a PR titled `i18n(<locale>): <area>`. Describe what was unclear, awkward, or wrong in the previous wording.
### Translating in the browser
Every locale can be edited in [Fink](https://fink.inlang.com/?repo=MBombeck/HealthLog&project=project.inlang), inlang's browser editor, without cloning the repository. Fink reads `project.inlang/settings.json`, which points it at `messages/<locale>.json`, shows each message next to its translations, and flags strings that are missing or use other placeholders than English.
1. Open the link and sign in with GitHub.
2. Without write access to this repository, choose **Fork**. Fink asks you to install the Inlang GitHub App on your own account so it can push to your fork.
3. Edit, then choose **Push**. Fink commits to the fork; **Open pull request** takes you to GitHub's compare page, where you submit the change against `main`.
Fink writes the same nested JSON this repository uses and changes only the lines you edited. A string you add for a key the locale did not have yet goes to the end of its section instead of the position it has in `en.json`, which is harmless.
What a reviewer checks on a translation pull request:
- Only `messages/*.json` changed, and only the strings the description names.
- Every `{placeholder}` of the English string is still there and spelled the same. The i18n integrity test fails the build otherwise.
- Medical terms are the ones the language's clinical guidelines use, and the register matches the rest of the locale (`du` in German, `você` in Brazilian Portuguese).
- Words the app or the Telegram bot matches literally stay as they are: slash commands, `taken <name>`, and the unit symbols.
- Machine translation is fine as a first draft, but someone who reads the language has gone through every string before the pull request is opened, and the description says so.
- Validated questionnaire items (the PHQ-9, GAD-7, WHO-5 and SCI items, their answer options and their stems under `mentalHealth.items`, `mentalHealth.options`, `mentalHealth.gad7Options`, `mentalHealth.who5Options`, `mentalHealth.sciOptions`, `mentalHealth.stems`, `mentalHealth.functional*`) are never translated freely, not even as a draft. A language gets them only from a published, licensed validated version, copied verbatim and named in `src/lib/mental-health/instruments.ts`; until then the app shows the English original and the bundle carries none of those keys, which the i18n tests enforce.
To run the checks Fink shows on a local checkout: `npx @inlang/cli@3.4.4 check --project ./project.inlang`. It reads the files and changes nothing.
### Adding a new locale
If you want to introduce a locale that isn't shipped (e.g. `pt-BR`, `cs`):
If you want to introduce a locale that isn't shipped (e.g. `sv`, `cs`):
1. Open an issue first using the **Translation request** template. We'll confirm the locale is in scope and decide whether it joins the AI-initial-community-maintained tier or the maintained tier.
2. Copy `messages/en.json` to `messages/<locale>.json`.
2. Copy `messages/en.json` to `messages/<locale>.json`, then delete the questionnaire wording the new language has no validated version of (the test output lists the keys).
3. Translate. AI assistance is welcome as a starting point but the PR description should note whether it's an AI-initial pass or a fully human-reviewed one — that information drives the banner state.
4. Add the locale to the supported list in `src/lib/i18n/locales.ts` (or wherever the registry lives — grep `SUPPORTED_LOCALES`).
5. The Coach system prompts at `src/lib/ai/prompts/coach-prompt.ts` and `src/lib/ai/prompts/insight-generator.ts` and the doctor-report PDF strings need translation too. Include those in the same PR.
6. Run `pnpm test -- i18n-integrity` locally before submitting.
4. Add the locale to `locales` in `src/lib/i18n/config.ts` and to `locales` in `project.inlang/settings.json`, then run `pnpm typecheck`. Every per-locale table that still lacks the new locale fails to compile, and that list is the work: date and number formatting, the reply-language footers of the Coach and insights prompts, the outbound safety banks, the prose-grounding patterns and the no-key fallback copy. Include those in the same PR.
5. Run `pnpm test` before submitting. The i18n guards hold the new bundle to the English keys and placeholders.
### Style guide for translators
@@ -156,7 +176,7 @@ If you want to introduce a locale that isn't shipped (e.g. `pt-BR`, `cs`):
### Long-term
When the community grows beyond ad-hoc PRs, we'll spin up a self-hosted [Weblate](https://weblate.org/) instance. That replaces the JSON-PR workflow with a web UI but keeps every translation under our own roof — no third-party telemetry. Tracking issue: `v1.6 — Weblate self-hosted on edge-01`.
Fink covers browser editing today. It edits the files in this repository directly and keeps no copy of the translations anywhere else. If the community outgrows it, a self-hosted [Weblate](https://weblate.org/) instance remains the fallback. Tracking issue: `v1.6 — Weblate self-hosted on edge-01`.
## Code of Conduct
+18 -1
View File
@@ -4,6 +4,9 @@ RUN corepack enable && corepack prepare pnpm@11.15.1 --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# The dependency patches pnpm-workspace.yaml names; the frozen install
# refuses to run without them.
COPY patches patches
COPY scripts/prepare.mjs scripts/prepare.mjs
RUN pnpm install --frozen-lockfile --prod=false
@@ -57,6 +60,11 @@ RUN node -p "require('/app/node_modules/pdfjs-dist/package.json').version" \
# The same for @napi-rs/canvas, whose native binary must match its JS loader.
RUN node -p "require('/app/node_modules/@napi-rs/canvas/package.json').version" \
> /app/.canvas-version
# Every shipped locale's message catalog is present and parses, checked
# against the locale list in the source; the list is written out so the runner
# stage can check the catalogs the image actually carries.
RUN node scripts/verify-message-catalogs.mjs --dir messages \
--config src/lib/i18n/config.ts --write /app/.message-locales
# ── Stage 3: Production runner ─────────────────────────────
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS runner
@@ -105,6 +113,15 @@ RUN addgroup --system --gid 1001 nodejs && \
# Copy built assets
COPY --from=builder /app/public ./public
# The server reads its message catalogs from here on first use, one locale at
# a time (src/lib/i18n/server-catalogs.ts); they are not bundled.
COPY --from=builder /app/messages ./messages
# A missing or broken catalog is only noticed when a page asks for that
# locale, so check the copies in this stage against the builder's list.
COPY --from=builder /app/.message-locales /app/scripts/verify-message-catalogs.mjs /tmp/catalog-check/
RUN node /tmp/catalog-check/verify-message-catalogs.mjs --dir /app/messages \
--locales /tmp/catalog-check/.message-locales && \
rm -rf /tmp/catalog-check
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Next's standalone output must carry the externalized worker dependencies.
@@ -265,7 +282,7 @@ RUN mkdir -p /opt/prisma-cli && \
npm pkg set 'overrides.mysql2=^3.22.0' && \
npm pkg set 'overrides.@hono/node-server=^1.19.15' && \
npm pkg set 'overrides.valibot=^1.4.2' && \
npm install --omit=dev prisma@7.10.0 @prisma/engines@7.10.0 tsx@4.23.1 dotenv@18.0.6 && \
npm install --omit=dev prisma@7.10.1 @prisma/engines@7.10.1 tsx@4.23.1 dotenv@18.0.8 && \
ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx && \
ln -sfn /opt/prisma-cli/node_modules/dotenv /app/node_modules/dotenv && \
ln -sfn /opt/prisma-cli/node_modules/prisma /app/node_modules/prisma && \
+9 -8
View File
@@ -56,7 +56,7 @@ Try the [live demo](https://demo.healthlog.dev) to see a working install — sig
- **AI insights you own.** Daily briefing, health scores, correlations, and a conversational coach grounded in your own measurements and interpreted against cited guideline reference bands — where a value sits and what that means in plain words, never a diagnosis. The coach proposes concrete if-then plans you accept or decline, and suppresses proactive check-ins when there's nothing genuine to say. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so AI requests stay on your network.
- **Clinician-ready output.** A doctor-report PDF rendered on your server, a read-only HL7 FHIR R4 API, and scoped, time-limited share links you revoke after the visit.
- **Private by construction.** AES-256-GCM encryption at rest for notes and other free text, documents, questionnaire answers, AI-written text and credentials, with zero-downtime key rotation. Measurement values, lab values, medication names and mood scores are stored unencrypted in the database, so the database volume and its backups need the same protection as the host. Passkey login, server-side sessions, strict CSP, and no third-party tracking anywhere.
- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, Polish, Korean, and Dutch end to end.
- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, Polish, Korean, Dutch, and Brazilian Portuguese end to end.
The full feature tour, integration guides, and API reference live at [docs.healthlog.dev](https://docs.healthlog.dev).
@@ -109,13 +109,14 @@ Next.js 16 (App Router, React Server Components), TypeScript strict, PostgreSQL
## Documentation
| | |
| ---------------------- | ------------------------------------------------ |
| User and operator docs | [docs.healthlog.dev](https://docs.healthlog.dev) |
| Release history | [CHANGELOG.md](CHANGELOG.md) |
| API contract | [`docs/api/openapi.yaml`](docs/api/openapi.yaml) |
| Security policy | [SECURITY.md](SECURITY.md) |
| Contributing | [CONTRIBUTING.md](CONTRIBUTING.md) |
| | |
| ---------------------- | ------------------------------------------------------------ |
| User and operator docs | [docs.healthlog.dev](https://docs.healthlog.dev) |
| Release history | [CHANGELOG.md](CHANGELOG.md) |
| API contract | [`docs/api/openapi.yaml`](docs/api/openapi.yaml) |
| Security policy | [SECURITY.md](SECURITY.md) |
| Contributing | [CONTRIBUTING.md](CONTRIBUTING.md) |
| Translating | [CONTRIBUTING.md#translations](CONTRIBUTING.md#translations) |
## Status
+117 -10
View File
@@ -1,14 +1,121 @@
{
"$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": 355,
"/insights/page": 323,
"/measurements/page": 335,
"/insights/mood/page": 334,
"/settings/[section]/page": 307
},
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs --check after `pnpm build` in CI; the script header describes the method. Numbers are KB gzip. routeBaselineKbGz is the measured first load of every page (the shared rootMainFiles plus the page's client-reference chunks, each file counted once by content hash), written with --write-baselines; a page fails when it is more than routeDriftAllowanceKbGz over its baseline, and a page without a baseline fails until one is written in the same change. firstLoadP95KbGz and firstLoadMaxKbGz cap the distribution across pages. uniqueClientKbGz caps the distinct client code: every module counted once by its module id, the lazy message catalogs excluded. maxCatalogKbGz holds each catalog, maxRechartsChunks the one shared chart chunk. The summed-chunk aggregate the gate used up to v1.44.0 (3875 KB there, capped at 3900) is still printed but no longer gated: it counted a module once per chunk group it landed in, so it rose whenever a page moved code into on-demand chunks and needed a raise in most releases while no reader downloaded more. Re-based at v1.45.0 from the v1.44.0 build, measured on a local arm64 machine: 110 pages, first load p50 336, p95 437, max 451 KB; unique client code 1904 KB in 1224 modules. The five pages watched before keep their numbers within a kilobyte (/ 359, /measurements 339, /insights/mood 337, /insights 323, /settings/[section] 311), and the metric pages that no watched route covered before are gated now too (/insights/weight 339, /insights/pulse 339). Caps: p95 447 and max 461 KB (10 KB over the measurement), unique client code 1930 KB (about 25 KB, roughly two releases of new client code). A raise or a re-base is a reviewed change with the measured numbers and the reason written here; earlier re-bases are in the history of this file.",
"routeDriftAllowanceKbGz": 20,
"totalClientKbGz": 3900,
"firstLoadP95KbGz": 447,
"firstLoadMaxKbGz": 461,
"uniqueClientKbGz": 1930,
"maxRechartsChunks": 1,
"maxCatalogKbGz": 210
"maxCatalogKbGz": 210,
"routeBaselineKbGz": {
"/_global-error/page": 133,
"/_not-found/page": 282,
"/about/page": 282,
"/achievements/page": 292,
"/admin/[section]/page": 451,
"/admin/page": 297,
"/auth/claim/page": 298,
"/auth/login/page": 290,
"/auth/register/page": 288,
"/c/[token]/page": 284,
"/checkups/page": 365,
"/checkups/practitioners/page": 289,
"/claim/[token]/page": 282,
"/coach/conversations/page": 294,
"/coach/page": 355,
"/coach/plans/page": 288,
"/confirm-access/page": 287,
"/custom-metrics/[id]/page": 303,
"/custom-metrics/[id]/values/page": 311,
"/cycle/page": 412,
"/documents/page": 378,
"/enroll-mfa/page": 305,
"/illness/[id]/page": 447,
"/illness/page": 421,
"/insights/active-energy/page": 336,
"/insights/audio-events/page": 337,
"/insights/blood-glucose/page": 340,
"/insights/blood-pressure/page": 336,
"/insights/bmi/page": 331,
"/insights/body-fat/page": 336,
"/insights/body-temperature/page": 336,
"/insights/body-water/page": 336,
"/insights/bone-mass/page": 336,
"/insights/breathing-disturbances/page": 337,
"/insights/cardio-fitness/page": 336,
"/insights/cardio-recovery/page": 337,
"/insights/catalog/page": 317,
"/insights/daylight/page": 336,
"/insights/double-support-time/page": 336,
"/insights/ecg/[id]/page": 316,
"/insights/ecg/page": 316,
"/insights/environmental-audio/page": 337,
"/insights/falls/page": 336,
"/insights/fat-free-mass/page": 336,
"/insights/fat-mass/page": 336,
"/insights/flights-climbed/page": 336,
"/insights/grip-strength/page": 337,
"/insights/headphone-audio/page": 336,
"/insights/health-score/page": 315,
"/insights/hrv/page": 337,
"/insights/lean-body-mass/page": 336,
"/insights/medications/page": 338,
"/insights/mood/page": 337,
"/insights/muscle-mass/page": 337,
"/insights/nutrients/page": 319,
"/insights/oxygen/page": 337,
"/insights/page": 323,
"/insights/pain/page": 336,
"/insights/pulse-wave-velocity/page": 336,
"/insights/pulse/page": 339,
"/insights/recovery/page": 330,
"/insights/respiratory-rate/page": 336,
"/insights/resting-pulse/page": 337,
"/insights/scores/[metric]/page": 328,
"/insights/six-minute-walk/page": 336,
"/insights/skin-temperature/page": 338,
"/insights/sleep/page": 337,
"/insights/sources/page": 321,
"/insights/stair-ascent-speed/page": 336,
"/insights/stair-descent-speed/page": 336,
"/insights/step-length/page": 336,
"/insights/steps/page": 337,
"/insights/values/[type]/page": 350,
"/insights/vascular-age/page": 337,
"/insights/visceral-fat/page": 336,
"/insights/waist-to-height/page": 336,
"/insights/waist/page": 336,
"/insights/walking-asymmetry/page": 336,
"/insights/walking-distance/page": 336,
"/insights/walking-heart-rate/page": 337,
"/insights/walking-speed/page": 336,
"/insights/walking-steadiness/page": 336,
"/insights/weight/page": 339,
"/insights/workouts/[id]/page": 437,
"/insights/workouts/page": 437,
"/insights/wrist-temperature/page": 336,
"/invite/[token]/page": 282,
"/labs/[biomarkerId]/page": 327,
"/labs/[biomarkerId]/values/page": 335,
"/labs/page": 423,
"/measurements/page": 339,
"/medications/[id]/history/page": 283,
"/medications/[id]/page": 402,
"/medications/new/page": 282,
"/medications/page": 355,
"/mental-wellbeing/page": 297,
"/mood/page": 438,
"/notifications/page": 289,
"/onboarding/[step]/page": 445,
"/onboarding/handover/page": 295,
"/onboarding/page": 286,
"/page": 359,
"/privacy/page": 282,
"/profile/page": 285,
"/settings/[section]/page": 311,
"/settings/layout/[module]/page": 380,
"/settings/modules/[module]/page": 374,
"/settings/page": 282,
"/timeline/page": 331,
"/vaccinations/page": 327
}
}
+6
View File
@@ -508,6 +508,11 @@ services:
# support report and Admin > System status say so. On a busy instance,
# raise it with APP_MEMORY_LIMIT in .env; do not lower it.
mem_limit: "${APP_MEMORY_LIMIT:-1536m}"
# On `docker stop` (a redeploy) the app stops taking requests, lets the
# requests in flight finish and gives running jobs up to 30 s to end
# before it exits. Docker's default of 10 s cut that short and left
# jobs marked active until their expiry, holding exclusive queues.
stop_grace_period: 45s
healthcheck:
test:
[
@@ -564,6 +569,7 @@ services:
# max-size: "10m"
# max-file: "3"
# mem_limit: "${WORKER_MEMORY_LIMIT:-1536m}"
# stop_grace_period: 45s
db:
# Pinned to the concrete minor instead of the floating `16-alpine`
+6
View File
@@ -59,6 +59,12 @@ notes, and machine-readable specs that ship alongside the source:
- [`contributing/ui-standards.md`](./contributing/ui-standards.md) — the
binding UI standards: card anatomy, spacing, text colour, tokens and
which primitive to use where. Read before touching any surface.
- [`contributing/benchmarks.md`](./contributing/benchmarks.md) — the
large-account benchmark (latency and statements per request) and the
statement budgets on the hot routes.
- [`contributing/testing.md`](./contributing/testing.md) — which suite a
test belongs in, the route test kit, the integration database, sharding,
property and mutation tests, and the flaky-test quarantine.
- [`ui-guidelines.md`](./ui-guidelines.md) — UI / a11y house style; where
it disagrees with the UI standards, the standards win.
+1441 -731
View File
File diff suppressed because it is too large. Load diff
+119
View File
@@ -0,0 +1,119 @@
# Benchmarks
Two tools measure the server's read and write paths. Use them before and after any change to a hot path, and put both numbers in the PR.
- **`scripts/bench/run.ts`** measures latency (p50, p95, max) and SQL statements per request against a seeded account of about 1.26 million measurement rows, through the production build. It is a local tool, not a CI gate: latency depends on the machine.
- **`tests/integration/query-budget-hot-routes.test.ts`** holds a statement budget per hot route on a small fixture. Statement counts are exact on a fixed fixture, so this one is a gate: a change that adds a query per row, or a second read of something already loaded, fails it.
## The large-account benchmark
### 1. A throwaway Postgres with pg_stat_statements
Never point the seed or the benchmark at a database that holds real data. They write under a synthetic user, and the seed's `--reset` deletes that user with everything it owns.
```sh
docker run -d --name hl-bench-db --shm-size=256m \
-e POSTGRES_USER=bench -e POSTGRES_PASSWORD=bench -e POSTGRES_DB=healthlog_bench \
-p 55432:5432 postgres:16.15-alpine \
postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=top
```
The image and the two settings are the ones `docker-compose.yml` runs in production.
### 2. Environment, migrations, seed
```sh
export DATABASE_URL="postgresql://bench:bench@127.0.0.1:55432/healthlog_bench?schema=public"
export ENCRYPTION_KEYS='{"v1":"<64 hex>"}' ENCRYPTION_ACTIVE_KEY_ID=v1
export API_TOKEN_HMAC_KEY="<64 hex>"
pnpm db:migrate:deploy
pnpm dlx tsx scripts/bench/seed-large-account.ts --reset --anchor 2026-10-22
```
Any throwaway key values work, as long as the seed, the benchmark and the server see the same ones.
The seed writes ten years for one user: Apple Health dense rows (heart rate about every ten minutes, HRV, SpO2, respiratory rate, gait, audio), Withings blood pressure, pulse and scale, a weekly manual weight, three years of Oura resting heart rate and RMSSD, 90 days of Nightscout CGM, `stats:` and per-sample cumulative rows, nightly sleep stages, about 3,000 workouts, three years of mood and of three medications with their intake events. It then folds the rollup tier the way the boot backfill does and runs `ANALYZE`. It takes about 80 seconds on the Mac mini.
The seed is deterministic for a given `--anchor`: values and timestamps are functions of the row index and that day. Without `--anchor` it anchors on today, which is what you want when the benchmark should see recent data.
### 3. A production build
```sh
pnpm build
```
### 4. Run
```sh
pnpm dlx tsx scripts/bench/run.ts --out scripts/bench/results/<label>
```
The script boots the build once with every role, so the worker creates its queues as it does in production, drops whatever that boot scheduled, and then runs the measurement against a web-only process (`HEALTHLOG_PROCESS_TYPE=web`) under the production heap ceiling (`--max-old-space-size=792`, the value Node derives in the 1.5 GB production container). Without the worker, its polling does not land in every statement count. It prints the server's idle statement rate so the remaining background is visible.
For each route it sends three warm-up requests, then twenty measured ones in each of two modes, one request at a time:
- **cold**: before every request a one-row ingest marked as a manual sync evicts the user's caches, so the request rebuilds what it serves. This is a reader's first look after a sync.
- **warm**: back to back, what a cache hit costs.
The batch ingest posts 5,000 fresh heart-rate rows as ten requests of 500, the route's per-request limit, and removes them again at the end.
Statements are the change in `sum(calls)` of `pg_stat_statements` over the database, read before and after each request, once no statement is in flight and the counter holds still. Fire-and-forget writes a request starts are therefore counted with it.
Options: `--iterations`, `--warmup`, `--user`, `--heap-mb`, `--port`, and `--base-url` to measure a server you started yourself.
The output is `<label>.json` (every number, plus the commit, Node and Postgres versions and the row count) and `<label>.md` (the table). Commit the pair when a change is meant to move a number, so the PR shows before and after.
### Reading the numbers
Run on a quiet machine; a parallel build or test run moves latency by tens of percent and leaves the statement counts alone. Compare statement counts across machines, latency only across runs on the same machine. A local number is only valid under the production heap ceiling, which the script sets.
## Boot and idle-memory profile
`scripts/bench/boot-profile.ts` profiles a production start the way production runs it: `next start` on the build, every role (web and worker), the production heap ceiling (`--max-old-space-size=792`, the heap Node derives in the 1.5 GB container), against the seeded benchmark database.
```sh
pnpm dlx tsx scripts/bench/boot-profile.ts --idle-seconds 300 --out scripts/bench/results/<label>-boot
```
It records:
- the time to the first answer from `/api/health` and to the worker's `startup` line;
- every `event_loop_stall` the process logs, with the uptime, startup phase, boot step and work in flight it names;
- every `runtime.heap.sampled` line, and every full collection from `--trace-gc` (used heap before, live heap after);
- a CPU profile of the start: the main thread's busy stretches over 50 ms without an idle sample, each with its self time per source module, resolved through the chunk source maps (`profile-analyze.ts`);
- one heap snapshot after the idle wait, taken with `--heapsnapshot-signal` (after a full collection, so it is the live heap), summarised by node type, constructor, largest strings and the largest retained subtrees with a retaining path, and the number of parsed locale catalogs.
The worker runs for the whole idle wait, and its boot passes fold and consolidate the seeded account (old dense readings into their `stats:` rows), roughly halving its row count. Reseed before the next request benchmark, or its numbers describe a different account.
The raw `.cpuprofile` and `.heapsnapshot` go to a temporary directory and are deleted after the summary unless `--keep` is given. Only run it against the benchmark database: a snapshot holds whatever the process decrypted. `--no-snapshot` and `--no-cpu-prof` skip either part. The profiler itself costs memory, so read RSS from this run as an upper bound.
### What the profile found (v1.44 runtime, October 2026)
On the seeded account, idle, with the worker running and no requests (`results/v1.45.0-before-boot.md`):
- **The boot stall is module evaluation.** The longest busy stretch of the main thread was 753 ms, 0.7 s into the start: the instrumentation hook importing the worker, whose five registrars pulled practically all of `src/lib` into one synchronous evaluation (`register-maintenance.ts` alone 280 ms inclusive), followed by the first `/api/health` request compiling its route. The event-loop monitor started after the key and time-zone checks, and a 250 ms tick measures a block by how late the next tick fires, so it reported less than the block or nothing. The beta's 718 ms and 671 ms stalls are this block on a slower machine.
- **The live heap is about 165 MB, not 400.** Full collections brought the heap back to 162 to 172 MB every 15 to 20 s while idle; the used heap between them reached 190 MB. What a heap line shows above the live heap is garbage awaiting the next full collection, plus, on a server with users, the server caches (13 caches of up to 2,000 entries with stale windows of up to an hour). The beta's 400 MB plateau is therefore not a baseline the process cannot shed; it is what the heap grows to under a large limit before a collection, plus caches.
- **What the live heap holds.** Of 172 MB: the query compiler about 50 MB (the engine object 34 MB, its WebAssembly base64 source twice at 4.4 MB, its chunk source twice at 5.6 MB); the locale catalogs about 33 MB (32 parsed catalog objects, eight locales in each of four server bundles, 14 MB, plus the eight-locale chunk source twice at 9.7 MB); module source strings over 50 KB about 44 MB in total (the catalog chunks included); compiled code 19 MB.
### What changed (v1.45.0)
- The event-loop monitor starts first in `register()`, and a stall line names the boot step, startup phase and work in flight (`src/lib/boot/boot-steps.ts`, one `boot.steps` line per boot with each step's duration).
- The worker's registrars are imported one at a time after `boss.start()`, with a turn of the event loop between them.
- The server reads a locale catalog from `messages/<locale>.json` on first use and keeps one parsed copy per process (`src/lib/i18n/server-catalogs.ts`); the seven matchers that index every locale build their index on first use.
Result on the same machine (`results/v1.45.0-boot.md`): the worker graph loads in stretches of at most 246 ms instead of one of 753 ms (the longest application stretch left is the query compiler's start, 336 ms, unchanged); the live heap after five idle minutes went from 162 to 172 MB to 142 to 153 MB; two parsed catalogs (1.6 MB) instead of 32 (14.1 MB), and no catalog chunk sources.
## Statement budgets in the integration suite
`tests/integration/query-budget-hot-routes.test.ts` seeds a small account (four months of the main types with overlapping sources, sleep, glucose, a medication, workouts and mood), and counts the statements of one request per hot route on a cache miss: `/api/auth/me`, the dashboard summary and snapshot, the pulse series over 30, 365 and 3650 days, the day view, insights health status, correlations, and a 500-row batch ingest.
The counter (`tests/integration/query-counter.ts`) wraps `pg`'s `Client.prototype.query` on the copy of `pg` the Prisma adapter loads, and settles background tasks before it closes the window.
Each budget is the measured count. When a change lowers one, lower the budget in the same change. When a change has to raise one, raise it with the reason in the PR. A failure prints every statement the request sent, grouped by text, so an added loop shows up as one statement with a count next to it.
Run it like any integration file:
```sh
pnpm test:integration tests/integration/query-budget-hot-routes.test.ts
```
+225
View File
@@ -0,0 +1,225 @@
# Testing
How the suites are organised, which one a new test belongs in, and the tools
that keep them fast and honest. Commands are in `CONTRIBUTING.md`; this page
is about choosing and writing tests.
## Which suite
| What you are testing | Where | Runs with |
| --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | ----------------------- |
| Pure logic: statistics, time zones, cadence, parsers, unit conversion, formatting | `src/**/__tests__/*.test.ts` (unit) | `pnpm test` |
| Laws of that logic over random inputs | `*.property.test.ts` next to the unit tests | `pnpm test` |
| What a component renders for a given state | `src/**/__tests__/*.test.tsx` (unit) | `pnpm test` |
| A structural rule about the tree (a guard) | `src/__tests__/` with a stated floor | `pnpm test` |
| A route: auth, scope, validation, what it stores and what it answers | `tests/integration/routes/` with the kit | `pnpm test:integration` |
| Anything whose truth lives in Postgres: constraints, `where` semantics, transactions, locks, cascades, time zones in SQL, backups, jobs | `tests/integration/` | `pnpm test:integration` |
| A journey through the real UI | `e2e/` | `pnpm e2e` |
The rule behind the table: put a behavioural test at the level where the
risky collaborator is real. For a route that collaborator is Postgres and the
API kit (`apiHandler`, `requireAuth`, the envelope), so a route test runs
against both. A unit test that replaces Prisma with a mock and then asserts on
the calls it received checks the query text, not the behaviour; it passes when
the query is wrong and fails when it is rewritten correctly. Do not write new
ones: `src/__tests__/test-realism-ratchet.test.ts` counts route tests that mock
`@/lib/db` wholesale or stub `apiHandler`, and `as never` casts in tests, and
those numbers may only fall. Mock at the process boundary you do not own instead: provider HTTP
(`safeFetch`), APNs, SMTP, the model.
## The route test kit
`tests/kit/` holds two modules for tests under `tests/integration/`.
`factories.ts` writes typed fixtures through the application's Prisma client
and returns the stored rows: `makeUser`, `makeAdmin`, `makeSession`,
`makeToken`, `makeMeasurement`, `makeMedication`, `makeWorkout`, `makeGrant`.
Overrides are the Prisma create input of that model, so the compiler checks
every fixture field; there is no reason for `as never` in a kit test.
Sessions and tokens are minted by the server's own `createSession` and
`issueApiToken`, so a fixture cannot drift into a shape authentication no
longer accepts.
`call-route.ts` exports `callRoute(handler, options)`, which builds a real
`NextRequest`, fills the `next/headers` stand-in from `auth`, calls the
exported handler of a `route.ts` (and with it the real `apiHandler`), and
returns `{ status, data, error, meta, body, headers }`.
```ts
import { GET } from "@/app/api/tokens/route";
import { DELETE } from "@/app/api/tokens/[id]/route";
import { callRoute } from "../../kit/call-route";
import { makeSession, makeToken, makeUser } from "../../kit/factories";
import { getPrismaClient, truncateAllTables } from "../setup";
beforeEach(() => truncateAllTables(getPrismaClient()));
it("revokes the caller's token and the token stops authenticating", async () => {
const owner = await makeUser();
const phone = await makeToken(owner);
const res = await callRoute<{ revoked: boolean }>(DELETE, {
method: "DELETE",
route: "/api/tokens/{id}",
params: { id: phone.tokenId },
auth: await makeSession(owner),
});
expect(res.status).toBe(200);
const row = await getPrismaClient().apiToken.findUniqueOrThrow({
where: { id: phone.tokenId },
});
expect(row.revoked).toBe(true);
const after = await callRoute(GET, {
method: "GET",
route: "/api/tokens",
auth: phone,
});
expect(after.status).toBe(401);
});
```
`auth` is a `SessionFixture`, a `BearerFixture`, or `anonymous`. `route` is
the path as the OpenAPI registry names it, with `{…}` segments filled from
`params`.
Every call is also a contract check. The status must be one the registry
(`src/lib/openapi/routes/`) declares for that path and method, a JSON body
must parse with the registered schema, and a key the schema does not declare
fails the call: a client generated from the contract cannot see that field, so
nobody promised to keep it. There is no generic answer the kit accepts on an
operation's behalf: the scope gate's 403 `auth.scope.insufficient` is declared
on every operation that passes the gate (`withScopeRefusal` in
`src/lib/openapi/routes/shared.ts`), so it is checked like any other status. A
test that calls an unregistered route on purpose passes
`contract: { skip: "<reason>" }`.
`tests/integration/routes/kit-contract.test.ts` proves the check can fail.
Assert what a person or a client would notice: the stored row, the response,
the next request's answer. Not the calls a mock received.
The kit runs the handler, not the edge. Security headers, the public-path
allowlist and demo mode live in `src/proxy.ts` and are tested there.
### The settings round trip
`tests/integration/settings-round-trip.test.ts` holds one table with an entry
for every `User` column the backup classifies as `SETTING`
(`USER_COLUMN_BACKUP_CLASS` in `src/lib/export/backup-plan.ts`). For each one
it sets a non-default value through the API, reads it back through the API and
through `/api/auth/me` where that publishes it, exports a backup, restores it
into a fresh account and reads it back there. A new setting column without a
table entry fails the file. A column with no write route says why in its
entry.
## The integration database
Global setup boots one Postgres container and migrates one template database.
Each Vitest worker clones it on its first file (`CREATE DATABASE … TEMPLATE`,
see `tests/integration/worker-database.ts`), so files run in parallel without
seeing each other's rows, advisory locks or queue schema. The worker count
follows the host (half the cores, at most eight) unless
`HEALTHLOG_INTEGRATION_WORKERS` pins it; CI uses three. To reproduce a CI runner's
contention on a larger machine, cap the database container with
`HEALTHLOG_INTEGRATION_PG_CPUS=2`.
`truncateAllTables` empties every table the migrations created and puts back
the rows they seed (the mood-tag and symptom catalogues), so a test starts
from what a fresh install holds. The list is the catalogue, read from the
template, not something to maintain. `START_EMPTY_TABLES` names the one
seeded table a test starts without, and why. Schemas created at runtime
(pg-boss) are dropped when the next file starts in the worker.
`next/headers` and the boot compatibility probe are mocked for every file in
`environment-setup.ts`. A file needs its own `vi.mock("next/headers")` only
when it wants different behaviour.
## Speed and sharding
CI splits both suites by measured duration, not by file count: the sequencer
in `tests/durations/sequencer.ts` reads `tests/durations/unit.json` and
`tests/durations/integration.json` and deals files out longest first to the
shard with the least work. A file without a measurement counts as the median.
Regenerate a timing file from Vitest JSON reports when the shards drift apart:
```sh
pnpm exec vitest run --reporter=default --reporter=json --outputFile.json=unit.json
node scripts/test-durations.mjs unit unit.json
```
The nightly workflow (`.github/workflows/test-nightly.yml`) uploads a file
regenerated from CI timings, which is the better source.
The unit suite is three projects, decided per file from its source
(`tests/unit-projects.ts`): `guards` and `unit` share a module graph within a
worker, `isolated` gives each file a fresh one. A file lands in `isolated` when
it mocks, spies, stubs env or globals, fakes timers or resets modules. If a
file needs isolation for a reason the classifier cannot see, put a
`// vitest-isolate` comment in it.
## Property tests
`fast-check` properties live in `*.property.test.ts` files and take their
parameters from `tests/property/fast-check.ts`. Pull requests run every
property with a fixed seed, so a run is reproducible on any machine. The
nightly run uses a fresh `FC_SEED` and multiplies every property's own case
count by `FC_RUNS_FACTOR`; a failure prints the seed and the shrunk
counterexample, and `FC_SEED=<seed> pnpm test <file>` replays it. A property
whose cases are expensive states a small count and passes
`propertyTimeout(baseMs)` as its test timeout, which grows by the same factor.
Write a property when the code has a law: an inverse (encrypt and decrypt, a
unit and its inverse, a wall clock and its instant), an invariant (a rate
between 0 and 100, a day that contains its own instants), or an oracle small
enough to be obviously right (a day-by-day walk of a weekly schedule). Do not
reimplement the function under test in the property.
## Mutation testing
Stryker runs on the core libraries (`stryker.config.mjs`) nightly and on
demand (`.github/workflows/mutation.yml`), not on pull requests. Each module's
score is checked against a floor in `stryker.floors.json` by
`node scripts/mutation-floors.mjs`. A floor sits just below the last measured
score; raise it when a change raises the score, never lower it to make a run
pass. The Vitest runner is patched for Vitest 5's test names
(`patches/@stryker-mutator__vitest-runner@10.0.0.patch`); without it every
test inside a `describe` block is filtered out of the mutant runs and almost
every mutant survives. Locally:
```sh
pnpm exec stryker run
node scripts/mutation-floors.mjs
```
## Flaky tests
Vitest runs without retries, and stays that way: a retry that turns red into
green hides the flake it was meant to report. e2e keeps two retries with
`failOnFlakyTests`, so a test that needed one still fails the run.
A flaky unit or integration test may be taken out of the run only through
`tests/quarantine/quarantine.json`:
```json
{
"file": "tests/integration/example.test.ts",
"test": "outer describe > the test name",
"issue": "#1234",
"reason": "fails about one run in twenty when the queue worker is slow",
"added": "2026-10-22",
"expires": "2026-11-05"
}
```
The test is reported as skipped with its issue while the entry is live. An
entry expires at most 14 days after it was added; after its expiry date the
test runs again, and `tests/quarantine/quarantine-guard.test.ts` fails until
the entry is removed. The guard also fails on an entry that names a file or a
test that does not exist. Fix the flake, or delete the test if it no longer
earns its place; do not extend the date.
Order dependence is looked for on purpose: the nightly workflow runs both
suites with `--sequence.shuffle` and prints the seed. Replay a failure with
`--sequence.shuffle --sequence.seed=<seed>`.
+26
View File
@@ -109,6 +109,32 @@ that for you, and each boot retries). Anything else is a session you can
judge: a stuck client may be ended with `SELECT pg_terminate_backend(<pid>)`.
You never need to run `migrate resolve` by hand for this case.
### v1.45.0 notes
A single-container install (`HEALTHLOG_PROCESS_TYPE=all`, the shipped
compose file) needs no action: the sequence above applies the one migration
and the job-queue upgrade on the first boot.
- Migration `0471` adds one nullable column to `healthkit_type_syncs`. It is
additive and rewrites no rows.
- The job queue library moves its own schema from version 45 to 46 (new
columns on its queue table, four indexes built concurrently afterwards).
Only a worker process runs that upgrade; a web-only process opens its
send-only connection without migrating and refuses a schema older than
its own (`pg-boss database requires migrations`), so it cannot queue
anything until the upgrade has run. A web/worker split therefore deploys
in this order: stop both roles, start the new **worker** and wait until its
startup log line carries `"reminder_worker": "started"`, then start the new
web containers.
The other order leaves the web containers without a queue (imports and
document reads answer `503 Background worker is not running`) until they
are restarted after the worker. An old web container left running beside
the upgraded schema loses its queue the next time it reconnects.
- The shipped compose file gives the app `stop_grace_period: 45s`, so a
redeploy lets running jobs finish instead of cutting them off after
Docker's default ten seconds. A forked or older compose file should add the
same line to the app and worker services.
### v1.44.0 notes
Migration `0430` drops the `insights_cached_text` column from `users`. A
+3 -2
View File
@@ -165,8 +165,9 @@ singleton key with the run you start, so the two never overlap. The admin
card keeps showing only the runs you started.
The check also measures how far the table file has grown past what its live
rows need (`maintenance_bloat_factor` on the wide event, `bloat_factor` in the
job result). Past 3 it adds a warning to the event: a vacuum makes the space
rows need (`maintenance_bloat_factor` on the wide event, as a factor such as
1.3; `bloat_percent` in the job result, as whole percent such as 130, because a
job fact is a whole number). Past 3 it adds a warning to the event: a vacuum makes the space
reusable but does not shrink the file, and only `VACUUM FULL` does (see
below).
+30 -6
View File
@@ -524,13 +524,37 @@ test.describe("the bottom sheet", () => {
test("under reduced motion it opens without a slide", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/mood");
await expect(page.locator('[data-testid="bottom-nav-more"]')).toBeVisible();
// Read the sheet the moment it is inserted, before the browser has drawn
// a frame of it: that is when a slide that exists at all is still
// running, however short it was made. A read after it shows depends on
// frames having been drawn in between, which WebKit in the CI container
// does not promise, so a later read alone can pass or fail at random.
await page.evaluate(() => {
const w = window as unknown as { __sheetAtInsert?: unknown };
const watch = new MutationObserver(() => {
const sheet = document.querySelector(
'[data-testid="bottom-nav-more-sheet"]',
);
const overlay = document.querySelector('[data-slot="sheet-overlay"]');
if (!sheet || !overlay || w.__sheetAtInsert) return;
watch.disconnect();
w.__sheetAtInsert = [sheet, overlay].map((el) => ({
name: getComputedStyle(el).animationName,
animations: el.getAnimations().length,
}));
});
watch.observe(document.body, { childList: true, subtree: true });
});
await page.locator('[data-testid="bottom-nav-more"]').tap();
await expect(more(page)).toBeVisible();
expect(
await more(page).evaluate(
(el) =>
el.getAnimations().filter((a) => a.playState === "running").length,
),
).toBe(0);
const atInsert = await page.evaluate(
() =>
(window as unknown as { __sheetAtInsert?: unknown }).__sheetAtInsert,
);
expect(atInsert).toEqual([
{ name: "none", animations: 0 },
{ name: "none", animations: 0 },
]);
});
});
+2 -4
View File
@@ -514,10 +514,8 @@ test.describe("Coach page frame", () => {
expect(await stripBox(strip(page))).toEqual(stripOpen);
await expect(expand(page)).toBeFocused();
await expect(expand(page)).toHaveAttribute("aria-expanded", "false");
await expect(expand(page)).toHaveAttribute(
"aria-label",
"Show conversations",
);
// Named by its visible label; the action is the tooltip (WCAG 2.5.3).
await expect(expand(page)).toHaveAttribute("title", "Show conversations");
await expect(
panel(page).locator('[data-slot="coach-history-select"]'),
).toHaveCount(0);
+3 -1
View File
@@ -192,7 +192,9 @@ test.describe("the day view", () => {
await expect(expand).toBeFocused();
await expect(expand).toHaveAttribute("aria-expanded", "false");
await expect(expand).toHaveAttribute("aria-controls", "day-docked-panel");
expect(await expand.getAttribute("aria-label")).toMatch(/^Show /);
// Named by its visible date (WCAG 2.5.3); the action is the tooltip.
expect(await expand.getAttribute("aria-label")).toBeNull();
expect(await expand.getAttribute("title")).toMatch(/^Show /);
expect((await rail.boundingBox())!.width).toBeLessThanOrEqual(40);
// Another page: the edge is still there and brings the same day back,
+173
View File
@@ -0,0 +1,173 @@
import AxeBuilder from "@axe-core/playwright";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* One full check-in per instrument through the shared wizard: the stem is
* on every step, the answers form one radio group in the source order, the
* number keys answer, the review lists every answer and changes one, and
* the result carries score, band, meaning and the next step. The PHQ-9 run
* answers item 9 above zero, so its result must lead with the crisis card.
*
* The total on the result is the server's; this journey adds the chosen
* option values itself and compares, which is the scoring contract seen
* from the outside (`instruments.test.ts` pins it from the inside). Each
* screen is scanned with axe on the way.
*
* Anchored on data attributes only, so the journey reads the same at every
* width and in every language.
*/
type Case = {
id: "PHQ9" | "GAD7" | "WHO5" | "SCI";
items: number;
options: number;
multiplier: number;
followUp: boolean;
};
const CASES: readonly Case[] = [
{ id: "PHQ9", items: 9, options: 4, multiplier: 1, followUp: true },
{ id: "GAD7", items: 7, options: 4, multiplier: 1, followUp: false },
{ id: "WHO5", items: 5, options: 6, multiplier: 4, followUp: false },
{ id: "SCI", items: 8, options: 5, multiplier: 1, followUp: false },
];
async function expectNoSeriousViolations(page: Page, label: string) {
// The pointer rests where the last tap landed; a hover tint under it is
// not what the screen shows at rest.
await page.mouse.move(0, 0);
await page.waitForTimeout(300);
const results = await new AxeBuilder({ page })
.include(
'[data-slot="mental-health-check-in"], [data-slot="mental-health-result"]',
)
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
const blocking = results.violations
.filter((v) => v.impact === "serious" || v.impact === "critical")
.map(
(v) =>
`${v.id}: ${v.nodes.map((n) => `${n.html} ${n.failureSummary ?? ""}`).join(" | ")}`,
);
expect(blocking, `axe on ${label}`).toEqual([]);
}
test.describe("mental-health check-in, every instrument", () => {
test.use({ storageState: STORAGE_STATE_PATH });
for (const c of CASES) {
test(`${c.id}: steps, review, result`, async ({ page }) => {
test.setTimeout(90_000);
await page.goto("/mental-wellbeing");
const card = page.locator(
`[data-slot="instrument-card"][data-instrument="${c.id}"]`,
);
await card.locator('[data-slot="instrument-card-start"]').click();
const wizard = page.locator(
`[data-slot="mental-health-check-in"][data-instrument="${c.id}"]`,
);
await expect(wizard).toBeVisible();
const values: number[] = [];
for (let step = 1; step <= c.items; step++) {
const current = wizard.locator(
`[data-slot="check-in-step"][data-step="${step}"]`,
);
await expect(current).toBeVisible();
await expect(
current.locator('[data-slot="check-in-stem"]'),
).toHaveCount(1);
const group = current.locator('[role="radiogroup"]');
await expect(group.locator('[role="radio"]')).toHaveCount(c.options);
if (step === 1) await expectNoSeriousViolations(page, `${c.id} step 1`);
// PHQ-9 item 9 answered above zero: the option in the last position
// is "nearly every day" (value 3).
const position =
c.id === "PHQ9" && step === 9 ? c.options : (step % c.options) + 1;
const answered = group.locator('[role="radio"]').nth(position - 1);
values.push(Number(await answered.getAttribute("data-value")));
if (step % 2 === 0) {
// Keyboard: the number key picks the answer in that position.
await group.locator('[role="radio"]').first().focus();
await page.keyboard.press(String(position));
} else {
await group
.locator('[role="radio"]')
.nth(position - 1)
.click();
}
}
if (c.followUp) {
const followUp = wizard.locator(
'[data-slot="check-in-step"][data-functional]',
);
await expect(followUp).toBeVisible();
await followUp.locator('[role="radio"]').nth(1).click();
await wizard.locator('[data-slot="check-in-next"]').click();
}
const review = wizard.locator('[data-slot="check-in-review"]');
await expect(review).toBeVisible();
await expect(
review.locator('[data-slot="check-in-review-row"]'),
).toHaveCount(c.items + (c.followUp ? 1 : 0));
await expectNoSeriousViolations(page, `${c.id} review`);
// Change the second answer from the review; the wizard returns there.
await review.locator('[data-review-edit="2"]').click();
const second = wizard.locator(
'[data-slot="check-in-step"][data-step="2"]',
);
await expect(second).toBeVisible();
const other =
values[1] ===
Number(
await second
.locator('[role="radio"]')
.first()
.getAttribute("data-value"),
)
? 1
: 0;
await second.locator('[role="radio"]').nth(other).click();
await expect(review).toBeVisible();
values[1] = Number(
await review
.locator('[data-slot="check-in-review-row"][data-step="2"]')
.getAttribute("data-value"),
);
await wizard.locator('[data-slot="check-in-submit"]').click();
const result = page.locator(
`[data-slot="mental-health-result"][data-instrument="${c.id}"]`,
);
await expect(result).toBeVisible();
const total = values.reduce((sum, v) => sum + v, 0) * c.multiplier;
await expect(result.locator('[data-slot="result-score"]')).toContainText(
String(total),
);
await expect(result.locator('[data-slot="result-band"]')).toBeVisible();
await expect(
result.locator('[data-slot="result-meaning"]'),
).toBeVisible();
await expect(
result.locator(
'[data-slot="result-follow-up-hint"], [data-slot="result-next-step"]',
),
).toHaveCount(1);
if (c.id === "PHQ9") {
await expect(
result.locator('[data-slot="mental-health-crisis-card"]'),
).toBeVisible();
}
await expectNoSeriousViolations(page, `${c.id} result`);
});
}
});
+1 -1
View File
@@ -5,7 +5,7 @@ import type { APIRequestContext } from "@playwright/test";
import { ADMIN_SECTION_SLUGS } from "@/components/admin/section-slugs";
import { MODULE_SETTINGS_PAGE_IDS } from "@/components/settings/module-settings-page-ids";
import { SETTINGS_SECTION_SLUGS } from "@/components/settings/section-slugs";
import { SETTINGS_SECTION_SLUGS } from "@/lib/settings/section-slugs";
import { expect, test } from "./setup/test";
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
+11 -1
View File
@@ -33,7 +33,17 @@ import {
type SetupScreen,
} from "./setup-flow-helpers";
const LOCALES = ["en", "de", "es", "fr", "it", "pl", "ko", "nl"] as const;
const LOCALES = [
"en",
"de",
"es",
"fr",
"it",
"pl",
"ko",
"nl",
"pt-BR",
] as const;
const VIEWPORTS = [
{ label: "desktop 1280", width: 1280, height: 800 },
+11
View File
@@ -332,6 +332,11 @@ test("value lines: quarterly means, gaps kept, short ones bridged, as many as th
);
expect(dots).toHaveLength(15);
await page.keyboard.press("Escape");
// The menu closes on an exit animation and only then hands focus back to
// its trigger, which scrolls the page to the toolbar. Measured before
// that, the scroll to the last row below was undone a moment later.
await expect(options).toHaveCount(0);
await expect(trigger).toBeFocused();
await expect
.poll(() => log.timelineQueries.at(-1)?.get("values")?.split(",").length)
.toBe(15);
@@ -504,6 +509,12 @@ test("a chosen range lives in the URL: reload keeps it, Back returns to it", asy
expect(await chart.boundingBox()).toEqual(before);
await expect(desktop.locator('[data-slot="timeline-range"]')).toHaveCount(0);
expect(page.url()).not.toContain("zoom=range");
// The popover slides and scales in (`slide-in-from-top-2 zoom-in-95`);
// measured mid-animation it sat up to 8 px higher than where it comes to
// rest. Read its place once the entry animation has finished.
await popover.evaluate((el) =>
Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished)),
);
const anchor = await zoom("range").boundingBox();
const box = await popover.boundingBox();
expect(box!.y).toBeGreaterThanOrEqual(anchor!.y + anchor!.height);
@@ -0,0 +1,147 @@
/**
* Unit tests for the `healthlog/import-boundaries` rule: src/lib never
* imports components or app, and a "use client" file never makes a value
* import of a server-only module. Covers alias and relative specifiers,
* type-only imports, dynamic imports, re-exports, the allow map and its
* stale-entry report.
*/
import { describe, it } from "vitest";
import { RuleTester } from "eslint";
import nextTs from "eslint-config-next/typescript";
import rule from "../import-boundaries.js";
// The TypeScript parser the app lints with, taken from the same config so
// `import type` reaches the rule as it does in `pnpm lint`.
const tsParser = nextTs.find((c) => c.languageOptions?.parser).languageOptions
.parser;
RuleTester.describe = describe;
RuleTester.it = it;
RuleTester.itOnly = it.only;
const ruleTester = new RuleTester({
languageOptions: {
parser: tsParser,
ecmaVersion: 2023,
sourceType: "module",
parserOptions: { ecmaFeatures: { jsx: true } },
},
});
const LIB_FILE = "/repo/src/lib/medications/example.ts";
const CLIENT_FILE = "/repo/src/components/medications/example.tsx";
ruleTester.run("import-boundaries", rule, {
valid: [
// lib importing lib, a package, and itself relatively.
{ code: 'import { a } from "@/lib/x";', filename: LIB_FILE },
{ code: 'import { z } from "zod";', filename: LIB_FILE },
{ code: 'import { b } from "../y";', filename: LIB_FILE },
// components importing components and lib is the right direction.
{
code: 'import { C } from "@/components/ui/card";',
filename: CLIENT_FILE,
},
// Tests are out of scope.
{
code: 'import { C } from "@/components/ui/card";',
filename: "/repo/src/lib/medications/__tests__/x.test.ts",
},
// An allowed lib file that still imports upward.
{
code: 'import { C } from "@/components/ui/card";',
filename: LIB_FILE,
options: [{ allow: { "src/lib/medications/example.ts": "reason" } }],
},
// A server component (no directive) may import the database client.
{
code: 'import { prisma } from "@/lib/db";',
filename: "/repo/src/app/page.tsx",
},
// A client file may import types from server modules and the enums entry.
{
code: '"use client";\nimport type { MeasurementType } from "@/generated/prisma/client";',
filename: CLIENT_FILE,
},
{
code: '"use client";\nimport { type BackupRestoreJobView } from "@/lib/jobs/backup-restore";',
filename: CLIENT_FILE,
},
{
code: '"use client";\nimport { MeasurementType } from "@/generated/prisma/enums";',
filename: CLIENT_FILE,
},
// A string that only looks like the directive is not one.
{
code: 'const x = "use client";\nimport { prisma } from "@/lib/db";',
filename: CLIENT_FILE,
},
],
invalid: [
{
code: 'import { C } from "@/components/ui/card";',
filename: LIB_FILE,
errors: [{ messageId: "libUpward" }],
},
{
code: 'import type { P } from "@/app/settings/page";',
filename: LIB_FILE,
errors: [{ messageId: "libUpward" }],
},
// Relative specifiers resolve to the same layer check.
{
code: 'import { C } from "../../components/ui/card";',
filename: LIB_FILE,
errors: [{ messageId: "libUpward" }],
},
{
code: 'export { C } from "@/components/ui/card";',
filename: LIB_FILE,
errors: [{ messageId: "libUpward" }],
},
{
code: 'const m = await import("@/components/ui/card");',
filename: LIB_FILE,
errors: [{ messageId: "libUpward" }],
},
// An allowed file that stopped importing upward must leave the list.
{
code: 'import { a } from "@/lib/x";',
filename: LIB_FILE,
options: [{ allow: { "src/lib/medications/example.ts": "reason" } }],
errors: [{ messageId: "staleAllow" }],
},
// Server-only modules in a client file.
{
code: '"use client";\nimport { prisma } from "@/lib/db";',
filename: CLIENT_FILE,
errors: [{ messageId: "clientServerOnly" }],
},
{
code: '"use client";\nimport { encrypt } from "@/lib/crypto";',
filename: CLIENT_FILE,
errors: [{ messageId: "clientServerOnly" }],
},
{
code: '"use client";\nimport { MeasurementType } from "@/generated/prisma/client";',
filename: CLIENT_FILE,
errors: [{ messageId: "clientServerOnly" }],
},
{
code: '"use client";\nimport { enqueue } from "@/lib/jobs/boss-instance";',
filename: CLIENT_FILE,
errors: [{ messageId: "clientServerOnly" }],
},
{
code: '"use client";\nimport { cookies } from "next/headers";',
filename: CLIENT_FILE,
errors: [{ messageId: "clientServerOnly" }],
},
{
code: '"use client";\nimport { Pool } from "pg";',
filename: CLIENT_FILE,
errors: [{ messageId: "clientServerOnly" }],
},
],
});
@@ -0,0 +1,199 @@
/**
* @fileoverview ESLint rule — import direction between the layers of `src`.
*
* Two boundaries, both checked per import statement so the mistake shows up
* in the editor rather than in a later sweep:
*
* 1. `src/lib/**` never imports from `src/components/**` or `src/app/**`.
* The direction is `app → components → hooks → lib`. A lib module that
* reaches upward drags a component folder into server jobs and makes
* the layers impossible to split. Type-only imports count too: the
* dependency is the same, only erased at build time.
*
* 2. A `"use client"` file never makes a value import of a server-only
* module: the database client, the crypto module, the Prisma client
* entry, the job queue, `pg`, `next/headers`. Type-only imports are
* erased and stay legal, and the Prisma `enums` entry is browser-safe.
* The transitive version of this check is
* `src/__tests__/client-bundle-server-import-guard.test.ts`, which walks
* the whole client graph; this rule is the direct, local half.
*
* Existing lib → components imports are listed in the `allow` option, file
* by file, each with the reason it has not moved yet. A listed file that no
* longer imports upward is reported too, so the list can only shrink.
*
* WHAT THIS DOES NOT CATCH
*
* A dependency through a re-export in a package outside `src`, and a
* `require()` call. Neither occurs in the tree today.
*/
"use strict";
const path = require("node:path");
function toPosix(filename) {
return filename.replace(/\\/g, "/");
}
function isTestFile(filename) {
return (
/\/__tests__\//.test(filename) ||
/\.(test|spec)\.[cm]?[jt]sx?$/.test(filename) ||
/\/__mocks__\//.test(filename)
);
}
/** Path of `filename` relative to the repository's `src/`, or null. */
function srcRelative(filename) {
const idx = filename.lastIndexOf("/src/");
return idx === -1 ? null : filename.slice(idx + 1);
}
/** Where a specifier lands inside `src/`, or null for a package. */
function targetInSrc(spec, filename) {
if (spec.startsWith("@/")) return `src/${spec.slice(2)}`;
if (spec.startsWith(".")) {
const abs = toPosix(path.resolve(path.dirname(filename), spec));
return srcRelative(abs);
}
return null;
}
const SERVER_ONLY_PACKAGES = [
/^pg$/,
/^pg-boss$/,
/^@prisma\//,
/^next\/headers$/,
];
const SERVER_ONLY_SRC = [
/^src\/lib\/db(-session)?$/,
/^src\/lib\/crypto(\/|$)/,
/^src\/lib\/jobs\//,
/^src\/generated\/prisma\/(client|internal|models)(\/|$)/,
];
function isServerOnly(spec, filename) {
if (SERVER_ONLY_PACKAGES.some((re) => re.test(spec))) return true;
const target = targetInSrc(spec, filename);
if (!target) return false;
const bare = target.replace(/\.(ts|tsx|js|mjs)$/, "");
return SERVER_ONLY_SRC.some((re) => re.test(bare));
}
function isTypeOnly(node) {
if (node.importKind === "type" || node.exportKind === "type") return true;
const specifiers = node.specifiers ?? [];
return (
specifiers.length > 0 &&
specifiers.every((s) => s.importKind === "type" || s.exportKind === "type")
);
}
function hasUseClient(program) {
for (const stmt of program.body) {
if (stmt.type !== "ExpressionStatement" || !stmt.directive) break;
if (stmt.directive === "use client") return true;
}
return false;
}
/** @type {import("eslint").Rule.RuleModule} */
const importBoundariesRule = {
meta: {
type: "problem",
docs: {
description:
"Keep src/lib below components and app, and server-only modules out of client files.",
},
schema: [
{
type: "object",
properties: {
allow: {
type: "object",
additionalProperties: { type: "string", minLength: 1 },
},
},
additionalProperties: false,
},
],
messages: {
libUpward:
"src/lib must not import {{target}}. Move the shared piece into src/lib (or the importer into the component folder); an exception goes into the rule's `allow` map in eslint.config.mjs with its reason.",
staleAllow:
"This file is in the import-boundaries `allow` map but no longer imports from src/components or src/app. Remove the entry so the list keeps shrinking.",
clientServerOnly:
'A "use client" file must not import {{spec}}: it is server-only and would ship to the browser. Import a type with `import type`, or move the call behind an API route or a server component.',
},
},
create(context) {
const filename = toPosix(context.filename ?? context.getFilename?.() ?? "");
const rel = srcRelative(filename);
if (!rel || isTestFile(filename)) return {};
const allow = (context.options[0] && context.options[0].allow) || {};
const inLib = rel.startsWith("src/lib/");
const allowed = inLib && Object.hasOwn(allow, rel);
let upwardSeen = false;
let isClient = false;
function check(node, source) {
if (!source || typeof source.value !== "string") return;
const spec = source.value;
if (inLib) {
const target = targetInSrc(spec, filename);
if (
target &&
(target.startsWith("src/components/") ||
target.startsWith("src/app/"))
) {
upwardSeen = true;
if (!allowed) {
context.report({
node: source,
messageId: "libUpward",
data: { target: spec },
});
}
}
}
if (isClient && !isTypeOnly(node) && isServerOnly(spec, filename)) {
context.report({
node: source,
messageId: "clientServerOnly",
data: { spec },
});
}
}
return {
Program(program) {
isClient = hasUseClient(program);
},
ImportDeclaration(node) {
check(node, node.source);
},
ExportNamedDeclaration(node) {
if (node.source) check(node, node.source);
},
ExportAllDeclaration(node) {
check(node, node.source);
},
ImportExpression(node) {
if (node.source && node.source.type === "Literal") {
check(node, node.source);
}
},
"Program:exit"(program) {
if (allowed && !upwardSeen) {
context.report({ node: program, messageId: "staleAllow" });
}
},
};
},
};
module.exports = importBoundariesRule;
+4
View File
@@ -21,6 +21,8 @@
* toLocaleString().
* - `no-raw-card-shell` — a card surface is a <Card> or a shell
* primitive, not bg-card + border + rounded-xl.
* - `import-boundaries` — src/lib never imports components or app, and
* a "use client" file imports no server module.
*/
"use strict";
@@ -36,6 +38,7 @@ const noUtcDayKey = require("./no-utc-day-key.js");
const noPlanningTags = require("./no-planning-tags.js");
const noUnlocalisedDecimal = require("./no-unlocalised-decimal.js");
const noRawCardShell = require("./no-raw-card-shell.js");
const importBoundaries = require("./import-boundaries.js");
module.exports = {
rules: {
@@ -55,5 +58,6 @@ module.exports = {
"no-planning-tags": noPlanningTags,
"no-unlocalised-decimal": noUnlocalisedDecimal,
"no-raw-card-shell": noRawCardShell,
"import-boundaries": importBoundaries,
},
};
+21
View File
@@ -176,6 +176,27 @@ const eslintConfig = defineConfig([
],
},
],
// Layer direction: src/lib never imports src/components or src/app, and
// a "use client" file makes no value import of the database client,
// crypto, the Prisma client entry, the job queue, pg or next/headers.
// The files below still import upward; each says why it has not moved.
// A listed file that stops importing upward fails until it is removed,
// so the map only shrinks. See the rule header.
"healthlog/import-boundaries": [
"error",
{
allow: {
"src/lib/insights/metric-catalog.ts":
"reads SUB_PAGE_TABS, which is declared inside the client tab strip component together with its labels and order",
"src/lib/insights/target-adjust-context.tsx":
"a React provider that renders TargetEditSheet; it belongs under components but has importers across 21 insight pages",
"src/lib/errors/error-toast.tsx":
"a React toast renderer that renders ErrorIdLine; it belongs under components but is imported by most client surfaces",
"src/lib/module-list-prefs.ts":
"a client hook module that raises the shared outcome toast; it moves with the module list settings surface",
},
},
],
},
},
// v1.28.17 — every recharts-rendering component funnels through the
+77 -64
View File
@@ -3231,11 +3231,11 @@
"last30days": "{days} Tage",
"last90days": "{days} Tage",
"lastYear": "{months} Monate",
"allTime": "Alles"
"allTime": "{months} Monate im Detail, Labor länger"
},
"detailDays": "Der Coach liest nichts, was älter als {days} Tage ist, in keiner Antwort.",
"detailMonths": "Der Coach liest nichts, was älter als {months} Monate ist, in keiner Antwort.",
"detailAll": "Alles, was du erfasst hast: {dailyMonths} Monate tagesgenau, ältere Werte als Monatswerte bis {years} Jahre zurück, Laborwerte der letzten {labMonths} Monate.",
"detailAll": "Ohne Begrenzung liest der Coach {dailyMonths} Monate tagesgenau und ältere Werte als Monatswerte bis {years} Jahre zurück. Von Laborwerten liest er den jüngsten Wert der zuletzt gemessenen Marker und, wenn du nach einem Marker fragst, dessen {labRecent} vorherige Werte; ältere Werte sind pro Jahr zusammengefasst, bis {labYears} Jahre zurück, noch frühere Jahre werden nur gezählt.",
"note": "Dein Profil, aktuelle Medikamente, eine laufende Erkrankung und anstehende Termine bleiben sichtbar; Auswertungen, die mehr Verlauf brauchen, werden gekürzt oder weggelassen. Antworten, die der Coach früher in älteren Unterhaltungen gegeben hat, können noch ältere Werte nennen."
},
"showConversations": "Unterhaltungen anzeigen",
@@ -3599,7 +3599,7 @@
"headphoneAudioBody": "Die Kopfhörerlautstärke erfasst in Dezibel, wie laut du über Ohrhörer oder Kopfhörer hörst. Die Lautstärke niedrig zu halten und Pausen einzulegen schützt dein Gehör langfristig.",
"audioEventsBody": "Laute Geräuschereignisse markieren Momente, in denen der Lärm um dich herum ein Niveau erreicht hat, das bei ausreichender Belastung das Gehör schädigen kann. Häufige Ereignisse sind ein Hinweis, eine ruhigere Umgebung oder Gehörschutz zu suchen.",
"daylightBody": "Die Zeit im Tageslicht ist, wie lange du dich täglich im Freien im Licht aufhältst. Regelmäßiges Tageslicht unterstützt einen gesunden Schlaf-Wach-Rhythmus und die Stimmung (CDC 2024), und Zeit im Freien ist mit der Augengesundheit verbunden.",
"bloodGlucoseBody": "Der Blutzucker ist die Zuckermenge in deinem Blut, die wichtigste Energiequelle des Körpers. Er steigt nach dem Essen und pendelt sich zwischen den Mahlzeiten ein; dauerhaft hohe Werte können auf Diabetes oder Prädiabetes hinweisen.",
"bloodGlucoseBody": "Der Blutzucker ist die Zuckermenge in deinem Blut, die wichtigste Energiequelle des Körpers. Er steigt nach dem Essen und pendelt sich zwischen den Mahlzeiten ein. Als allgemeine Orientierung für Erwachsene ohne Diabetes gilt ein Nüchternwert unter {prediabetesFrom} {unit} als normal, {prediabetesFrom}–{prediabetesTo} als Prädiabetes, und ab {diabetesFrom} liegt er im Diabetesbereich (ADA 2024). Ein einzelner hoher Wert ist keine Diagnose; eine Ärztin oder ein Arzt bestätigt sie mit einer Wiederholungsmessung, und wer Diabetes behandelt, arbeitet mit anderen, ärztlich festgelegten Zielwerten.",
"skinTemperatureBody": "Die Hauttemperatur ist hier die kleine nächtliche Abweichung deiner Handgelenkstemperatur von deinem eigenen Ausgangswert, keine absolute Fiebermessung. Veränderungen können Schlaf, Umgebung, Krankheit oder Zyklusphase widerspiegeln.",
"cardioFitnessBody": "VO₂max gibt an, wie viel Sauerstoff dein Körper bei maximaler Anstrengung verwerten kann. Es ist der beste Laborindex der Ausdauerfitness, den Wearables indirekt schätzen. Ein höherer oder steigender Trend ist ein starkes Zeichen für eine gute Herz-Kreislauf-Gesundheit.",
"cardioRecoveryBody": "Der Erholungspuls gibt an, um wie viele Schläge pro Minute dein Puls in der ersten Minute nach dem Ende einer anstrengenden Belastung fällt. Ein größerer Abfall bedeutet, dass sich dein Herz schneller beruhigt, ein Zeichen guter Fitness.",
@@ -7431,6 +7431,7 @@
"lastReceived": "Zuletzt empfangen {relative}",
"lastNewValue": "Letzter neuer Wert {relative}",
"lastBackground": "Zuletzt im Hintergrund {relative}",
"lastDuplicate": "Zuletzt ohne neue Werte erneut gesendet {relative}",
"never": "Noch nicht empfangen",
"lastBackfill": "Verlauf nachgeladen {relative}",
"lastEnteredHere": "Zuletzt hier eingetragen {relative}"
@@ -10427,6 +10428,8 @@
"yellow-fever": "Gelbfieber",
"japanese-encephalitis": "Japanische Enzephalitis",
"cholera": "Cholera",
"dengue": "Dengue",
"tuberculosis": "Tuberkulose",
"td": "Td (Tetanus, Diphtherie)",
"tdap": "Tdap (Tetanus, Diphtherie, Pertussis)",
"tdap-ipv": "Tdap-IPV (Vierfachimpfung)",
@@ -10950,11 +10953,17 @@
"2": "An mehr als der Hälfte der Tage",
"3": "Beinahe jeden Tag"
},
"functionalTitle": "Wenn du eine oder mehrere Beschwerden angegeben hast: Wie sehr haben diese deine Arbeit, deinen Haushalt oder den Umgang mit anderen Menschen erschwert?",
"gad7Options": {
"0": "Überhaupt nicht",
"1": "An einzelnen Tagen",
"2": "An mehr als der Hälfte der Tage",
"3": "Beinahe jeden Tag"
},
"functionalTitle": "Wenn eines oder mehrere der bisher in diesem Fragebogen beschriebenen Probleme bei Ihnen vorliegen, geben Sie bitte an, wie sehr diese Probleme es Ihnen erschwert haben, Ihre Arbeit zu tun, Ihren Haushalt zu regeln oder mit anderen Menschen zurecht zu kommen:",
"functional": {
"0": "Überhaupt nicht erschwert",
"1": "Etwas erschwert",
"2": "Sehr erschwert",
"2": "Stark erschwert",
"3": "Extrem erschwert"
},
"functionalOptionalHint": "Optional: Du kannst auch ohne Antwort auf diese Frage absenden.",
@@ -10962,7 +10971,10 @@
"title": "Dein Ergebnis",
"totalLabel": "Gesamtwert",
"bandLabel": "Bereich für dieses Screening",
"takeAnother": "Weiteren Check-in machen"
"takeAnother": "Weiteren Check-in machen",
"scoreOf": "von {max}",
"firstCheckIn": "Das ist dein erstes Ergebnis mit diesem Fragebogen. Ab dem nächsten siehst du hier die Veränderung.",
"nextStep": "Du kannst den Check-in in zwei Wochen oder später wiederholen, um die Veränderung zu sehen. Wenn dich etwas beunruhigt, sprich jederzeit mit einer Fachperson."
},
"band": {
"PHQ9": {
@@ -11011,8 +11023,8 @@
"4": "Müdigkeit oder Gefühl, keine Energie zu haben",
"5": "Verminderter Appetit oder übermäßiges Bedürfnis zu essen",
"6": "Schlechte Meinung von sich selbst; Gefühl, ein Versager zu sein oder die Familie enttäuscht zu haben",
"7": "Schwierigkeiten, sich auf etwas zu konzentrieren, z. B. beim Zeitunglesen oder Fernsehen",
"8": "Waren Ihre Bewegungen oder Ihre Sprache so verlangsamt, dass es auch anderen auffallen würde? Oder waren Sie im Gegenteil „zappelig“ oder ruhelos und hatten einen stärkeren Bewegungsdrang als sonst?",
"7": "Schwierigkeiten, sich auf etwas zu konzentrieren, z. B. beim Zeitungslesen oder Fernsehen",
"8": "Waren Ihre Bewegungen oder Ihre Sprache so verlangsamt, dass es auch anderen auffallen würde? Oder waren Sie im Gegenteil „zappelig“ oder ruhelos und hatten dadurch einen stärkeren Bewegungsdrang als sonst?",
"9": "Gedanken, dass Sie lieber tot wären oder sich Leid zufügen möchten"
},
"gad7": {
@@ -11020,9 +11032,9 @@
"2": "Nicht in der Lage sein, Sorgen zu stoppen oder zu kontrollieren",
"3": "Übermäßige Sorgen bezüglich verschiedener Angelegenheiten",
"4": "Schwierigkeiten zu entspannen",
"5": "Rastlosigkeit, sodass Stillsitzen schwerfällt",
"5": "Rastlosigkeit, so dass Stillsitzen schwer fällt",
"6": "Schnelle Verärgerung oder Gereiztheit",
"7": "Gefühl der Angst, als würde etwas Schlimmes passieren"
"7": "Gefühl der Angst, so als würde etwas Schlimmes passieren"
},
"who5": {
"1": "… war ich froh und guter Laune",
@@ -11030,16 +11042,6 @@
"3": "… habe ich mich energisch und aktiv gefühlt",
"4": "… habe ich mich beim Aufwachen frisch und ausgeruht gefühlt",
"5": "… war mein Alltag voller Dinge, die mich interessieren"
},
"sci": {
"1": "… how long does it take you to fall asleep?",
"2": "… if you then wake up during the night … how long are you awake for in total? (add all the wakenings up)",
"3": "… how many nights a week do you have a problem with your sleep?",
"4": "… how would you rate your sleep quality?",
"5": "… affected your mood, energy, or relationships?",
"6": "… affected your concentration, productivity, or ability to stay awake?",
"7": "… troubled you in general?",
"8": "… how long have you had a problem with your sleep?"
}
},
"crisisResource": {
@@ -11069,6 +11071,18 @@
},
"crisisTextLine": {
"name": "Crisis Text Line"
},
"cvv": {
"name": "CVV, Centro de Valorização da Vida (Brasilien, rund um die Uhr, kostenlos, vertraulich)"
},
"samu": {
"name": "SAMU, medizinischer Notruf (Brasilien)"
},
"sns24": {
"name": "SNS 24, psychologische Beratung (Portugal, rund um die Uhr, Option 4 wählen)"
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (Portugal, täglich 15:30 bis 0:30 Uhr)"
}
},
"instrumentDescription": {
@@ -11077,7 +11091,6 @@
"who5": "Wohlbefindens-Check mit 5 Fragen. Beziehe dich auf die letzten zwei Wochen; höhere Werte bedeuten besseres Wohlbefinden.",
"sci": "Schlaf-Check mit 8 Fragen. Die meisten Fragen beziehen sich auf den letzten Monat; höhere Werte bedeuten besseren Schlaf."
},
"progress": "{current} / {total}",
"next": "Weiter",
"lastResult": "Letzter Test",
"lastScore": "Letztes Ergebnis",
@@ -11098,51 +11111,15 @@
"4": "Meistens",
"5": "Die ganze Zeit"
},
"sciOptions": {
"duration": {
"0": "≥ 61 min",
"1": "46–60 min",
"2": "31–45 min",
"3": "16–30 min",
"4": "0–15 min"
},
"nights": {
"0": "5–7",
"1": "4",
"2": "3",
"3": "2",
"4": "0–1"
},
"quality": {
"0": "Very poor",
"1": "Poor",
"2": "Average",
"3": "Good",
"4": "Very good"
},
"impact": {
"0": "Very much",
"1": "Much",
"2": "Somewhat",
"3": "A little",
"4": "Not at all"
},
"problemDuration": {
"0": "> 1 year",
"1": "7–12 months",
"2": "3–6 months",
"3": "1–2 months",
"4": "I don't have a problem / < 1 month"
}
},
"stems": {
"phq9": {
"period": "Wie oft fühlten Sie sich im Verlauf der letzten 2 Wochen durch die folgenden Beschwerden beeinträchtigt?"
},
"gad7": {
"period": "Wie oft fühlten Sie sich im Verlauf der letzten 2 Wochen durch die folgenden Beschwerden beeinträchtigt?"
},
"who5": {
"period": "In den letzten zwei Wochen …"
},
"sci": {
"night": "Thinking about a typical night in the last month …",
"impact": "Thinking about the past month, to what extent has poor sleep …",
"finally": "Finally …"
}
},
"openDetail": "Details anzeigen",
@@ -11157,6 +11134,43 @@
"worsened": "Das erreicht die Veränderung von {threshold} Punkten, die Studien als bedeutsam ansehen, in die weniger gute Richtung.",
"within_threshold": "Das ist weniger als die Veränderung von {threshold} Punkten, die Studien als bedeutsam ansehen."
}
},
"wizard": {
"progressLabel": "Frage {current} von {total}",
"followUpLabel": "Zusatzfrage (freiwillig)",
"keyboardHint": "Mit den Zahlentasten 1 bis {count} wählst du eine Antwort.",
"allAnswered": "Alle {total} Fragen beantwortet"
},
"review": {
"title": "Antworten prüfen",
"description": "Gespeichert wird erst beim Absenden, bis dahin lässt sich jede Antwort noch ändern.",
"change": "Ändern",
"changeLabel": "Antwort auf Frage {number} ändern",
"notAnswered": "Nicht beantwortet",
"open": "Antworten prüfen"
},
"bandMeaning": {
"PHQ9": {
"minimal": "Deine Antworten weisen für die letzten zwei Wochen auf keine oder kaum depressive Beschwerden hin.",
"mild": "Deine Antworten weisen für die letzten zwei Wochen auf leichte depressive Beschwerden hin.",
"moderate": "Deine Antworten weisen für die letzten zwei Wochen auf mittelgradige depressive Beschwerden hin.",
"modSevere": "Deine Antworten weisen für die letzten zwei Wochen auf mittelschwere depressive Beschwerden hin.",
"severe": "Deine Antworten weisen für die letzten zwei Wochen auf schwere depressive Beschwerden hin."
},
"GAD7": {
"minimal": "Deine Antworten weisen für die letzten zwei Wochen auf keine oder kaum Angstbeschwerden hin.",
"mild": "Deine Antworten weisen für die letzten zwei Wochen auf leichte Angstbeschwerden hin.",
"moderate": "Deine Antworten weisen für die letzten zwei Wochen auf mittelgradige Angstbeschwerden hin.",
"severe": "Deine Antworten weisen für die letzten zwei Wochen auf schwere Angstbeschwerden hin."
},
"WHO5": {
"low": "Dein Wohlbefinden lag in den letzten zwei Wochen unter dem Bereich, den der WHO-5 als gut wertet.",
"good": "Dein Wohlbefinden lag in den letzten zwei Wochen in dem Bereich, den der WHO-5 als gut wertet."
},
"SCI": {
"belowThreshold": "Dein Schlaf lag im letzten Monat in dem Bereich, in dem die Studie hinter diesem Fragebogen eine Insomnie für wahrscheinlich hielt.",
"aboveThreshold": "Dein Schlaf lag im letzten Monat außerhalb des Bereichs, den diese Studie mit Insomnie verbindet."
}
}
},
"records": {
@@ -11894,7 +11908,6 @@
},
"chat": {
"title": "Über dieses Dokument chatten",
"openAria": "Coach über dieses Dokument fragen",
"openButton": "Coach fragen"
},
"readingConsent": {
+71 -6
View File
@@ -3231,11 +3231,11 @@
"last30days": "{days} days",
"last90days": "{days} days",
"lastYear": "{months} months",
"allTime": "All"
"allTime": "{months} months in detail, longer for labs"
},
"detailDays": "The Coach reads nothing older than {days} days, in any answer.",
"detailMonths": "The Coach reads nothing older than {months} months, in any answer.",
"detailAll": "Everything you recorded: {dailyMonths} months in daily detail, older readings as monthly values up to {years} years back, lab results from the last {labMonths} months.",
"detailAll": "Without a limit, the Coach reads {dailyMonths} months of readings in daily detail and older readings as monthly values up to {years} years back. For lab results it reads the latest value of the most recently measured markers and, for a marker you ask about, its {labRecent} previous results, with older ones summarised per year up to {labYears} years back; earlier years are only counted.",
"note": "Your profile, current medications, an ongoing illness and upcoming visits stay visible; summaries that need more history are shortened or left out. Answers the Coach gave earlier in older conversations may still quote older values."
},
"showConversations": "Show conversations",
@@ -7431,6 +7431,7 @@
"lastReceived": "Last received {relative}",
"lastNewValue": "Last new value {relative}",
"lastBackground": "Last in background {relative}",
"lastDuplicate": "Last re-sent with nothing new {relative}",
"never": "Not received yet",
"lastBackfill": "History caught up {relative}",
"lastEnteredHere": "Last entered here {relative}"
@@ -10427,6 +10428,8 @@
"yellow-fever": "Yellow fever",
"japanese-encephalitis": "Japanese encephalitis",
"cholera": "Cholera",
"dengue": "Dengue",
"tuberculosis": "Tuberculosis",
"td": "Td (tetanus, diphtheria)",
"tdap": "Tdap (tetanus, diphtheria, pertussis)",
"tdap-ipv": "Tdap-IPV (four-in-one)",
@@ -10950,7 +10953,13 @@
"2": "More than half the days",
"3": "Nearly every day"
},
"functionalTitle": "If you checked off any problems, how difficult have these made it to do your work, take care of things at home, or get along with other people?",
"gad7Options": {
"0": "Not at all",
"1": "Several days",
"2": "More than half the days",
"3": "Nearly every day"
},
"functionalTitle": "If you checked off any problems, how difficult have these problems made it for you to do your work, take care of things at home, or get along with other people?",
"functional": {
"0": "Not difficult at all",
"1": "Somewhat difficult",
@@ -10962,7 +10971,10 @@
"title": "Your result",
"totalLabel": "Total score",
"bandLabel": "Range for this screen",
"takeAnother": "Take another check-in"
"takeAnother": "Take another check-in",
"scoreOf": "of {max}",
"firstCheckIn": "This is your first result for this questionnaire. From the next one on, the change shows here.",
"nextStep": "You can repeat this check-in in two weeks or later to follow the change. If something worries you, talk to a health professional at any time."
},
"band": {
"PHQ9": {
@@ -11069,6 +11081,18 @@
},
"crisisTextLine": {
"name": "Crisis Text Line"
},
"cvv": {
"name": "CVV, Centro de Valorização da Vida (Brazil, 24/7, free, confidential)"
},
"samu": {
"name": "SAMU medical emergency service (Brazil)"
},
"sns24": {
"name": "SNS 24 psychological counselling (Portugal, 24/7, choose option 4)"
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (Portugal, daily 3.30 pm to 12.30 am)"
}
},
"instrumentDescription": {
@@ -11077,7 +11101,6 @@
"who5": "5-question well-being check. Answer for the last two weeks; higher numbers mean better well-being.",
"sci": "8-question sleep check. Most questions ask about the past month; higher scores mean better sleep."
},
"progress": "{current} / {total}",
"next": "Next",
"lastResult": "Last test",
"lastScore": "Last result",
@@ -11136,6 +11159,12 @@
}
},
"stems": {
"phq9": {
"period": "Over the last 2 weeks, how often have you been bothered by any of the following problems?"
},
"gad7": {
"period": "Over the last 2 weeks, how often have you been bothered by the following problems?"
},
"who5": {
"period": "Over the last two weeks …"
},
@@ -11157,6 +11186,43 @@
"worsened": "That reaches the {threshold}-point change studies treat as meaningful, in the less good direction.",
"within_threshold": "That is smaller than the {threshold}-point change studies treat as meaningful."
}
},
"wizard": {
"progressLabel": "Question {current} of {total}",
"followUpLabel": "Follow-up question (optional)",
"keyboardHint": "Number keys 1 to {count} choose an answer.",
"allAnswered": "All {total} questions answered"
},
"review": {
"title": "Check your answers",
"description": "Nothing is saved until you submit, and every answer can still be changed.",
"change": "Change",
"changeLabel": "Change the answer to question {number}",
"notAnswered": "Not answered",
"open": "Review answers"
},
"bandMeaning": {
"PHQ9": {
"minimal": "Your answers point to few or no symptoms of depression over the last two weeks.",
"mild": "Your answers point to mild symptoms of depression over the last two weeks.",
"moderate": "Your answers point to moderate symptoms of depression over the last two weeks.",
"modSevere": "Your answers point to moderately severe symptoms of depression over the last two weeks.",
"severe": "Your answers point to severe symptoms of depression over the last two weeks."
},
"GAD7": {
"minimal": "Your answers point to few or no symptoms of anxiety over the last two weeks.",
"mild": "Your answers point to mild symptoms of anxiety over the last two weeks.",
"moderate": "Your answers point to moderate symptoms of anxiety over the last two weeks.",
"severe": "Your answers point to severe symptoms of anxiety over the last two weeks."
},
"WHO5": {
"low": "Your well-being over the last two weeks was below the range the WHO-5 counts as good.",
"good": "Your well-being over the last two weeks was in the range the WHO-5 counts as good."
},
"SCI": {
"belowThreshold": "Your sleep over the last month was in the range where the study behind this questionnaire found insomnia likely.",
"aboveThreshold": "Your sleep over the last month was outside the range that study linked with insomnia."
}
}
},
"records": {
@@ -11894,7 +11960,6 @@
},
"chat": {
"title": "Chat about this document",
"openAria": "Ask the Coach about this document",
"openButton": "Ask the Coach"
},
"readingConsent": {
+95 -82
View File
@@ -1556,12 +1556,12 @@
"weekdays": {
"label": "Días de la semana",
"short": {
"mo": "Mo",
"mo": "Lu",
"tu": "Ma",
"we": "Mi",
"th": "Ju",
"fr": "Fr",
"sa": "Sa",
"fr": "Vi",
"sa": "Sá",
"su": "Do"
},
"long": {
@@ -3231,11 +3231,11 @@
"last30days": "{days} días",
"last90days": "{days} días",
"lastYear": "{months} meses",
"allTime": "Todo"
"allTime": "{months} meses en detalle, más para los análisis"
},
"detailDays": "El Coach no lee nada con más de {days} días de antigüedad, en ninguna respuesta.",
"detailMonths": "El Coach no lee nada con más de {months} meses de antigüedad, en ninguna respuesta.",
"detailAll": "Todo lo que has registrado: {dailyMonths} meses al detalle diario, las mediciones más antiguas como valores mensuales hasta {years} años atrás, los análisis de los últimos {labMonths} meses.",
"detailAll": "Sin límite, el Coach lee {dailyMonths} meses de mediciones al detalle diario y las más antiguas como valores mensuales hasta {years} años atrás. De los análisis lee el último valor de los marcadores medidos más recientemente y, de un marcador por el que preguntes, sus {labRecent} resultados anteriores; los más antiguos se resumen por año hasta {labYears} años atrás y los años anteriores solo se cuentan.",
"note": "Tu perfil, tu medicación actual, una enfermedad en curso y las próximas citas siguen visibles; los resúmenes que necesitan más historial se acortan o se omiten. Las respuestas que el Coach dio antes en conversaciones anteriores pueden seguir citando valores más antiguos."
},
"showConversations": "Mostrar conversaciones",
@@ -3599,7 +3599,7 @@
"headphoneAudioBody": "Los niveles de audio con auriculares registran en decibelios el volumen al que escuchas con auriculares. Bajar el volumen y hacer pausas protege tu audición a largo plazo.",
"audioEventsBody": "Los eventos de sonido fuerte señalan momentos en que el ruido a tu alrededor alcanzó un nivel que puede dañar la audición con suficiente exposición. Los eventos frecuentes son una señal para buscar un entorno más silencioso o protección auditiva.",
"daylightBody": "El tiempo a la luz del día es cuánto pasas bajo luz exterior cada día. La luz diurna regular favorece un ritmo sueño-vigilia y un estado de ánimo saludables (CDC 2024), y el tiempo al aire libre se asocia con la salud ocular.",
"bloodGlucoseBody": "La glucosa en sangre es la cantidad de azúcar en tu sangre, la principal fuente de energía del cuerpo. Sube tras comer y se estabiliza entre comidas; valores que se mantienen altos con el tiempo pueden indicar diabetes o prediabetes.",
"bloodGlucoseBody": "La glucosa en sangre es la cantidad de azúcar en tu sangre, la principal fuente de energía del cuerpo. Sube tras comer y se estabiliza entre comidas. Como orientación general para adultos sin diabetes, una glucosa en ayunas por debajo de {prediabetesFrom} {unit} es normal, entre {prediabetesFrom} y {prediabetesTo} es prediabetes, y a partir de {diabetesFrom} está en el rango de la diabetes (ADA 2024). Una sola lectura alta no es un diagnóstico; un profesional sanitario lo confirma con una prueba repetida, y quienes controlan una diabetes usan otros objetivos, fijados por su profesional sanitario.",
"skinTemperatureBody": "La temperatura de la piel aquí es el pequeño cambio nocturno de la temperatura de tu muñeca respecto a tu propio valor de referencia, no una medición absoluta de fiebre. Las variaciones pueden seguir el sueño, el entorno, una enfermedad o la fase del ciclo menstrual.",
"cardioFitnessBody": "El VO₂ máx es la cantidad de oxígeno que tu cuerpo puede usar al máximo esfuerzo: el mejor índice de laboratorio de la forma aeróbica, que los wearables estiman de forma indirecta. Una tendencia más alta o en aumento es una fuerte señal de salud cardiovascular.",
"cardioRecoveryBody": "La recuperación cardiaca es cuántas pulsaciones por minuto baja tu frecuencia cardiaca en el primer minuto tras parar un ejercicio intenso. Una caída mayor significa que tu corazón se calma más rápido, señal de buena forma física.",
@@ -7431,6 +7431,7 @@
"lastReceived": "Última recepción {relative}",
"lastNewValue": "Último valor nuevo {relative}",
"lastBackground": "Última vez en segundo plano {relative}",
"lastDuplicate": "Último reenvío sin datos nuevos {relative}",
"never": "Aún no recibido",
"lastBackfill": "Historial recuperado {relative}",
"lastEnteredHere": "Último registro aquí {relative}"
@@ -10427,6 +10428,8 @@
"yellow-fever": "Fiebre amarilla",
"japanese-encephalitis": "Encefalitis japonesa",
"cholera": "Cólera",
"dengue": "Dengue",
"tuberculosis": "Tuberculosis",
"td": "Td (tétanos, difteria)",
"tdap": "Tdap (tétanos, difteria, tos ferina)",
"tdap-ipv": "Tdap-IPV (tetravalente)",
@@ -10948,21 +10951,30 @@
"0": "Nunca",
"1": "Varios días",
"2": "Más de la mitad de los días",
"3": "Casi todos los días"
"3": "Casi cada día"
},
"functionalTitle": "Si marcaste algún problema, ¿cuánta dificultad te han causado para trabajar, ocuparte de la casa o relacionarte con otras personas?",
"gad7Options": {
"0": "Nunca",
"1": "Varios días",
"2": "Más de la mitad de los días",
"3": "Casi cada día"
},
"functionalTitle": "Si ha marcado alguno de los problemas de este cuestionario, ¿hasta qué punto estos problemas le han creado dificultades para hacer su trabajo, ocuparse de la casa o relacionarse con los demás?",
"functional": {
"0": "Ninguna dificultad",
"1": "Algo de dificultad",
"2": "Mucha dificultad",
"3": "Dificultad extrema"
"1": "Algunas dificultades",
"2": "Muchas dificultades",
"3": "Muchísimas dificultades"
},
"functionalOptionalHint": "Opcional: puedes enviar sin responder esta pregunta.",
"result": {
"title": "Tu resultado",
"totalLabel": "Puntuación total",
"bandLabel": "Rango para este cribado",
"takeAnother": "Hacer otra revisión"
"takeAnother": "Hacer otra revisión",
"scoreOf": "de {max}",
"firstCheckIn": "Es tu primer resultado con este cuestionario. A partir del próximo verás aquí el cambio.",
"nextStep": "Puedes repetir este cuestionario dentro de dos semanas o más tarde para seguir el cambio. Si algo te preocupa, habla con un profesional de la salud en cualquier momento."
},
"band": {
"PHQ9": {
@@ -11005,24 +11017,24 @@
},
"items": {
"phq9": {
"1": "Tener poco interés o placer en hacer las cosas",
"2": "Sentirse desanimado/a, deprimido/a o sin esperanza",
"3": "Tener problemas para dormir o mantenerse dormido/a, o dormir demasiado",
"4": "Sentirse cansado/a o tener poca energía",
"5": "Tener poco apetito o comer en exceso",
"6": "Sentirse mal con usted mismo/a, o sentir que es un fracaso o que se ha fallado a sí mismo/a o a su familia",
"7": "Tener dificultad para concentrarse en cosas tales como leer el periódico o ver la televisión",
"8": "¿Moverse o hablar tan despacio que otras personas podrían haberlo notado? O lo contrario: estar tan inquieto/a o agitado/a que se ha estado moviendo mucho más de lo habitual",
"9": "Pensar que estaría mejor muerto/a o en hacerse daño de alguna manera"
"1": "Poco interés o alegría por hacer cosas",
"2": "Sensación de estar decaído/a, deprimido/a o desesperanzado/a",
"3": "Problemas para quedarse dormido/a, para seguir durmiendo o dormir demasiado",
"4": "Sensación de cansancio o de tener poca energía",
"5": "Poco apetito o comer demasiado",
"6": "Sentirse mal consigo mismo/a; sentir que es un/a fracasado/a o que ha decepcionado a su familia o a sí mismo/a",
"7": "Problemas para concentrarse en algo, como leer el periódico o ver la televisión",
"8": "Moverse o hablar tan despacio que los demás pueden haberlo notado. O lo contrario: estar tan inquieto/a o agitado/a que se ha estado moviendo de un lado a otro más de lo habitual",
"9": "Pensamientos de que estaría mejor muerto/a o de querer hacerse daño de algún modo"
},
"gad7": {
"1": "Sentirse nervioso/a, ansioso/a o muy alterado/a",
"2": "No poder dejar de preocuparse o no poder controlar la preocupación",
"3": "Preocuparse demasiado por diferentes cosas",
"4": "Tener dificultad para relajarse",
"5": "Estar tan inquieto/a que es difícil quedarse sentado/a tranquilamente",
"6": "Molestarse o irritarse fácilmente",
"7": "Sentir miedo como si algo terrible fuera a suceder"
"1": "Sentirse nervioso/a, angustiado/a o muy tenso/a",
"2": "Ser incapaz de dejar de preocuparse o de controlar la preocupación",
"3": "Preocuparse demasiado por diferentes cuestiones",
"4": "Tener problemas para relajarse",
"5": "Estar tan inquieto/a que le resulta difícil permanecer sentado/a",
"6": "Enfadarse o irritarse con facilidad",
"7": "Sentir miedo de que algo terrible pueda ocurrir"
},
"who5": {
"1": "Me he sentido alegre y de buen humor",
@@ -11030,16 +11042,6 @@
"3": "Me he sentido activo y enérgico",
"4": "Me he despertado fresco y descansado",
"5": "Mi vida cotidiana ha estado llena de cosas que me interesan"
},
"sci": {
"1": "… how long does it take you to fall asleep?",
"2": "… if you then wake up during the night … how long are you awake for in total? (add all the wakenings up)",
"3": "… how many nights a week do you have a problem with your sleep?",
"4": "… how would you rate your sleep quality?",
"5": "… affected your mood, energy, or relationships?",
"6": "… affected your concentration, productivity, or ability to stay awake?",
"7": "… troubled you in general?",
"8": "… how long have you had a problem with your sleep?"
}
},
"crisisResource": {
@@ -11069,6 +11071,18 @@
},
"crisisTextLine": {
"name": "Crisis Text Line"
},
"cvv": {
"name": "CVV, Centro de Valorização da Vida (Brasil, 24/7, gratuito y confidencial)"
},
"samu": {
"name": "SAMU, urgencias médicas (Brasil)"
},
"sns24": {
"name": "SNS 24, asesoramiento psicológico (Portugal, 24/7, opción 4)"
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (Portugal, todos los días de 15:30 a 0:30)"
}
},
"instrumentDescription": {
@@ -11077,7 +11091,6 @@
"who5": "Test de bienestar de 5 preguntas. Responde pensando en las últimas dos semanas; cifras mayores significan mayor bienestar.",
"sci": "Test de sueño de 8 preguntas. La mayoría se refiere al último mes; puntuaciones mayores significan mejor sueño."
},
"progress": "{current} / {total}",
"next": "Siguiente",
"lastResult": "Última prueba",
"lastScore": "Último resultado",
@@ -11098,51 +11111,15 @@
"4": "La mayor parte del tiempo",
"5": "Todo el tiempo"
},
"sciOptions": {
"duration": {
"0": "≥ 61 min",
"1": "46–60 min",
"2": "31–45 min",
"3": "16–30 min",
"4": "0–15 min"
},
"nights": {
"0": "5–7",
"1": "4",
"2": "3",
"3": "2",
"4": "0–1"
},
"quality": {
"0": "Very poor",
"1": "Poor",
"2": "Average",
"3": "Good",
"4": "Very good"
},
"impact": {
"0": "Very much",
"1": "Much",
"2": "Somewhat",
"3": "A little",
"4": "Not at all"
},
"problemDuration": {
"0": "> 1 year",
"1": "7–12 months",
"2": "3–6 months",
"3": "1–2 months",
"4": "I don't have a problem / < 1 month"
}
},
"stems": {
"phq9": {
"period": "Durante las últimas 2 semanas ¿con qué frecuencia le ha molestado alguno de los siguientes problemas?"
},
"gad7": {
"period": "En los últimos 14 días, ¿con qué frecuencia le han supuesto una molestia los siguientes problemas?"
},
"who5": {
"period": "Durante las últimas dos semanas:"
},
"sci": {
"night": "Thinking about a typical night in the last month …",
"impact": "Thinking about the past month, to what extent has poor sleep …",
"finally": "Finally …"
}
},
"openDetail": "Ver detalles",
@@ -11157,6 +11134,43 @@
"worsened": "Alcanza el cambio de {threshold} puntos que los estudios consideran relevante, en la dirección menos favorable.",
"within_threshold": "Es menor que el cambio de {threshold} puntos que los estudios consideran relevante."
}
},
"wizard": {
"progressLabel": "Pregunta {current} de {total}",
"followUpLabel": "Pregunta adicional (opcional)",
"keyboardHint": "Las teclas 1 a {count} eligen una respuesta.",
"allAnswered": "Las {total} preguntas tienen respuesta"
},
"review": {
"title": "Revisar respuestas",
"description": "No se guarda nada hasta que lo envíes, y cada respuesta todavía se puede cambiar.",
"change": "Cambiar",
"changeLabel": "Cambiar la respuesta a la pregunta {number}",
"notAnswered": "Sin respuesta",
"open": "Revisar respuestas"
},
"bandMeaning": {
"PHQ9": {
"minimal": "Tus respuestas apuntan a pocos o ningún síntoma depresivo en las últimas dos semanas.",
"mild": "Tus respuestas apuntan a síntomas depresivos leves en las últimas dos semanas.",
"moderate": "Tus respuestas apuntan a síntomas depresivos moderados en las últimas dos semanas.",
"modSevere": "Tus respuestas apuntan a síntomas depresivos moderadamente graves en las últimas dos semanas.",
"severe": "Tus respuestas apuntan a síntomas depresivos graves en las últimas dos semanas."
},
"GAD7": {
"minimal": "Tus respuestas apuntan a pocos o ningún síntoma de ansiedad en las últimas dos semanas.",
"mild": "Tus respuestas apuntan a síntomas de ansiedad leves en las últimas dos semanas.",
"moderate": "Tus respuestas apuntan a síntomas de ansiedad moderados en las últimas dos semanas.",
"severe": "Tus respuestas apuntan a síntomas de ansiedad graves en las últimas dos semanas."
},
"WHO5": {
"low": "Tu bienestar en las últimas dos semanas estuvo por debajo del rango que el WHO-5 considera bueno.",
"good": "Tu bienestar en las últimas dos semanas estuvo dentro del rango que el WHO-5 considera bueno."
},
"SCI": {
"belowThreshold": "Tu sueño del último mes estuvo en el rango en el que el estudio detrás de este cuestionario consideró probable un insomnio.",
"aboveThreshold": "Tu sueño del último mes estuvo fuera del rango que ese estudio asocia con el insomnio."
}
}
},
"records": {
@@ -11894,7 +11908,6 @@
},
"chat": {
"title": "Chatea sobre este documento",
"openAria": "Pregunta al Coach sobre este documento",
"openButton": "Preguntar al Coach"
},
"readingConsent": {
+73 -87
View File
@@ -1556,11 +1556,11 @@
"weekdays": {
"label": "Jours de la semaine",
"short": {
"mo": "Mo",
"mo": "Lu",
"tu": "Ma",
"we": "Me",
"th": "Je",
"fr": "Fr",
"fr": "Ve",
"sa": "Sa",
"su": "Di"
},
@@ -3231,11 +3231,11 @@
"last30days": "{days} jours",
"last90days": "{days} jours",
"lastYear": "{months} mois",
"allTime": "Tout"
"allTime": "{months} mois en détail, plus pour les analyses"
},
"detailDays": "Le Coach ne lit rien de plus ancien que {days} jours, dans aucune réponse.",
"detailMonths": "Le Coach ne lit rien de plus ancien que {months} mois, dans aucune réponse.",
"detailAll": "Tout ce que vous avez enregistré : {dailyMonths} mois au jour près, les mesures plus anciennes en valeurs mensuelles jusqu’à {years} ans en arrière, les analyses des {labMonths} derniers mois.",
"detailAll": "Sans limite, le Coach lit {dailyMonths} mois de mesures au jour près et les mesures plus anciennes en valeurs mensuelles jusqu’à {years} ans en arrière. Pour les analyses, il lit la dernière valeur des marqueurs mesurés le plus récemment et, pour un marqueur sur lequel vous l’interrogez, ses {labRecent} résultats précédents ; les plus anciens sont résumés par année jusqu’à {labYears} ans en arrière, les années antérieures sont seulement comptées.",
"note": "Votre profil, vos médicaments actuels, une maladie en cours et vos prochains rendez-vous restent visibles ; les synthèses qui demandent plus d’historique sont raccourcies ou omises. Les réponses données plus tôt par le Coach dans d’anciennes conversations peuvent encore citer des valeurs plus anciennes."
},
"showConversations": "Afficher les conversations",
@@ -3599,7 +3599,7 @@
"headphoneAudioBody": "Les niveaux audio au casque suivent, en décibels, le volume auquel vous écoutez avec des écouteurs ou un casque. Baisser le volume et faire des pauses protège votre audition sur le long terme.",
"audioEventsBody": "Les événements sonores forts signalent les moments où le bruit autour de vous a atteint un niveau pouvant nuire à l'audition en cas d'exposition suffisante. Des événements fréquents invitent à rechercher un environnement plus calme ou une protection auditive.",
"daylightBody": "Le temps à la lumière du jour correspond à la durée passée en lumière extérieure chaque jour. Une lumière naturelle régulière soutient un rythme veille-sommeil et une humeur sains (CDC 2024), et le temps passé dehors est lié à la santé oculaire.",
"bloodGlucoseBody": "La glycémie est la quantité de sucre dans votre sang, principale source d'énergie de l'organisme. Elle augmente après les repas et se stabilise entre eux ; des valeurs durablement élevées peuvent signaler un diabète ou un prédiabète.",
"bloodGlucoseBody": "La glycémie est la quantité de sucre dans votre sang, principale source d'énergie de l'organisme. Elle augmente après les repas et se stabilise entre eux. À titre indicatif pour les adultes sans diabète, une glycémie à jeun inférieure à {prediabetesFrom} {unit} est normale, de {prediabetesFrom} à {prediabetesTo} elle correspond au prédiabète, et à partir de {diabetesFrom} elle se situe dans la zone du diabète (ADA 2024). Une seule valeur élevée ne suffit pas à poser un diagnostic : un médecin la confirme par un nouveau test, et les personnes qui suivent un diabète ont d'autres objectifs, fixés avec leur médecin.",
"skinTemperatureBody": "La température cutanée correspond ici à la petite variation nocturne de la température de votre poignet par rapport à votre propre référence, et non à une mesure absolue de fièvre. Les variations peuvent suivre le sommeil, l'environnement, une maladie ou la phase du cycle menstruel.",
"cardioFitnessBody": "Le VO₂ max correspond à la quantité d'oxygène que votre corps peut utiliser à l'effort maximal. C'est le meilleur indice de laboratoire de la forme aérobie, que les wearables estiment de façon indirecte. Une tendance plus élevée ou en hausse est un fort signe de santé cardiovasculaire.",
"cardioRecoveryBody": "La récupération cardiaque correspond au nombre de battements par minute dont votre fréquence cardiaque diminue dans la première minute après l'arrêt d'un effort intense. Une baisse plus forte signifie que votre cœur se calme plus vite, signe d'une bonne condition physique.",
@@ -7431,6 +7431,7 @@
"lastReceived": "Dernière réception {relative}",
"lastNewValue": "Dernière nouvelle valeur {relative}",
"lastBackground": "Dernière fois en arrière-plan {relative}",
"lastDuplicate": "Dernier renvoi sans nouvelle donnée {relative}",
"never": "Pas encore reçu",
"lastBackfill": "Historique rattrapé {relative}",
"lastEnteredHere": "Dernière saisie ici {relative}"
@@ -10427,6 +10428,8 @@
"yellow-fever": "Fièvre jaune",
"japanese-encephalitis": "Encéphalite japonaise",
"cholera": "Choléra",
"dengue": "Dengue",
"tuberculosis": "Tuberculose",
"td": "dT (tétanos, diphtérie)",
"tdap": "dTca (tétanos, diphtérie, coqueluche)",
"tdap-ipv": "dTca-VPI (quadruple)",
@@ -10944,25 +10947,21 @@
"who5": "Test de bien-être, 5 questions",
"sci": "Test de sommeil, 8 questions"
},
"options": {
"gad7Options": {
"0": "Jamais",
"1": "Plusieurs jours",
"2": "Plus de la moitié des jours",
"2": "Plus de la moitié du temps",
"3": "Presque tous les jours"
},
"functionalTitle": "Si tu as coché des problèmes, à quel point t'ont-ils gêné(e) dans ton travail, à la maison ou dans tes relations avec les autres ?",
"functional": {
"0": "Pas du tout gênant",
"1": "Un peu gênant",
"2": "Très gênant",
"3": "Extrêmement gênant"
},
"functionalOptionalHint": "Facultatif : tu peux envoyer sans répondre à cette question.",
"result": {
"title": "Ton résultat",
"totalLabel": "Score total",
"bandLabel": "Plage pour ce dépistage",
"takeAnother": "Faire un autre bilan"
"takeAnother": "Faire un autre bilan",
"scoreOf": "sur {max}",
"firstCheckIn": "C’est votre premier résultat pour ce questionnaire. À partir du prochain, l’évolution s’affichera ici.",
"nextStep": "Vous pouvez refaire ce questionnaire dans deux semaines ou plus tard pour suivre l’évolution. Si quelque chose vous inquiète, parlez-en à tout moment à un professionnel de santé."
},
"band": {
"PHQ9": {
@@ -11004,25 +11003,14 @@
"resourcesTitle": "Des personnes qui peuvent aider"
},
"items": {
"phq9": {
"1": "Peu d'intérêt ou de plaisir à faire les choses",
"2": "Se sentir triste, déprimé(e) ou désespéré(e)",
"3": "Difficultés à s'endormir ou à rester endormi(e), ou dormir trop",
"4": "Se sentir fatigué(e) ou avoir peu d'énergie",
"5": "Avoir peu d'appétit ou manger trop",
"6": "Avoir une mauvaise opinion de soi-même — ou avoir le sentiment d'être nul(le) ou d'avoir déçu sa famille ou soi-même",
"7": "Avoir du mal à se concentrer, par exemple pour lire le journal ou regarder la télévision",
"8": "Bouger ou parler si lentement que les autres ont pu le remarquer. Ou au contraire, être si agité(e) que vous avez bougé beaucoup plus que d'habitude",
"9": "Penser qu'il vaudrait mieux mourir ou envisager de vous faire du mal d'une manière ou d'une autre"
},
"gad7": {
"1": "Se sentir nerveux/nerveuse, anxieux/anxieuse ou tendu(e)",
"2": "Être incapable d'arrêter de vous inquiéter ou de contrôler vos inquiétudes",
"3": "Vous inquiéter trop à propos de choses diverses",
"4": "Avoir du mal à vous détendre",
"5": "Être si agité(e) qu'il est difficile de rester tranquille",
"6": "Devenir facilement contrarié(e) ou irritable",
"7": "Avoir peur que quelque chose d'épouvantable puisse arriver"
"1": "Un sentiment de nervosité, d’anxiété ou de tension",
"2": "Une incapacité à arrêter de s’inquiéter ou à contrôler ses inquiétudes",
"3": "Une inquiétude excessive à propos de différentes choses",
"4": "Des difficultés à se détendre",
"5": "Une agitation telle qu’il est difficile à tenir en place",
"6": "Une tendance à être facilement contrarié(e) ou irritable",
"7": "Un sentiment de peur comme si quelque chose de terrible risquait de se produire"
},
"who5": {
"1": "Je me suis senti(e) bien et de bonne humeur",
@@ -11030,16 +11018,6 @@
"3": "Je me suis senti(e) plein(e) d'énergie et vigoureux(se)",
"4": "Je me suis réveillé(e) en me sentant frais(che) et dispos(e)",
"5": "Ma vie quotidienne a été remplie de choses intéressantes"
},
"sci": {
"1": "… how long does it take you to fall asleep?",
"2": "… if you then wake up during the night … how long are you awake for in total? (add all the wakenings up)",
"3": "… how many nights a week do you have a problem with your sleep?",
"4": "… how would you rate your sleep quality?",
"5": "… affected your mood, energy, or relationships?",
"6": "… affected your concentration, productivity, or ability to stay awake?",
"7": "… troubled you in general?",
"8": "… how long have you had a problem with your sleep?"
}
},
"crisisResource": {
@@ -11069,6 +11047,18 @@
},
"crisisTextLine": {
"name": "Crisis Text Line"
},
"cvv": {
"name": "CVV, Centro de Valorização da Vida (Brésil, 24h/24, 7j/7, gratuit et confidentiel)"
},
"samu": {
"name": "SAMU, urgences médicales (Brésil)"
},
"sns24": {
"name": "SNS 24, soutien psychologique (Portugal, 24h/24, 7j/7, option 4)"
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (Portugal, tous les jours de 15h30 à 0h30)"
}
},
"instrumentDescription": {
@@ -11077,7 +11067,6 @@
"who5": "Test de bien-être en 5 questions. Répondez en pensant aux deux dernières semaines ; des chiffres plus élevés signifient un meilleur bien-être.",
"sci": "Test de sommeil en 8 questions. La plupart des questions portent sur le dernier mois ; des scores plus élevés signifient un meilleur sommeil."
},
"progress": "{current} / {total}",
"next": "Suivant",
"lastResult": "Dernier test",
"lastScore": "Dernier résultat",
@@ -11098,51 +11087,12 @@
"4": "La plupart du temps",
"5": "Tout le temps"
},
"sciOptions": {
"duration": {
"0": "≥ 61 min",
"1": "46–60 min",
"2": "31–45 min",
"3": "16–30 min",
"4": "0–15 min"
},
"nights": {
"0": "5–7",
"1": "4",
"2": "3",
"3": "2",
"4": "0–1"
},
"quality": {
"0": "Very poor",
"1": "Poor",
"2": "Average",
"3": "Good",
"4": "Very good"
},
"impact": {
"0": "Very much",
"1": "Much",
"2": "Somewhat",
"3": "A little",
"4": "Not at all"
},
"problemDuration": {
"0": "> 1 year",
"1": "7–12 months",
"2": "3–6 months",
"3": "1–2 months",
"4": "I don't have a problem / < 1 month"
}
},
"stems": {
"gad7": {
"period": "Au cours des 2 dernières semaines, selon quelle fréquence avez-vous été gêné(e) par les problèmes suivants ?"
},
"who5": {
"period": "Au cours des deux dernières semaines"
},
"sci": {
"night": "Thinking about a typical night in the last month …",
"impact": "Thinking about the past month, to what extent has poor sleep …",
"finally": "Finally …"
}
},
"openDetail": "Voir les détails",
@@ -11157,6 +11107,43 @@
"worsened": "Cela atteint la variation de {threshold} points que les études jugent significative, dans le sens moins favorable.",
"within_threshold": "C'est moins que la variation de {threshold} points que les études jugent significative."
}
},
"wizard": {
"progressLabel": "Question {current} sur {total}",
"followUpLabel": "Question complémentaire (facultative)",
"keyboardHint": "Les touches 1 à {count} choisissent une réponse.",
"allAnswered": "Les {total} questions ont une réponse"
},
"review": {
"title": "Vérifier les réponses",
"description": "Rien n’est enregistré avant l’envoi, et chaque réponse peut encore être modifiée.",
"change": "Modifier",
"changeLabel": "Modifier la réponse à la question {number}",
"notAnswered": "Sans réponse",
"open": "Vérifier les réponses"
},
"bandMeaning": {
"PHQ9": {
"minimal": "Vos réponses indiquent peu ou pas de symptômes dépressifs au cours des deux dernières semaines.",
"mild": "Vos réponses indiquent des symptômes dépressifs légers au cours des deux dernières semaines.",
"moderate": "Vos réponses indiquent des symptômes dépressifs modérés au cours des deux dernières semaines.",
"modSevere": "Vos réponses indiquent des symptômes dépressifs modérément sévères au cours des deux dernières semaines.",
"severe": "Vos réponses indiquent des symptômes dépressifs sévères au cours des deux dernières semaines."
},
"GAD7": {
"minimal": "Vos réponses indiquent peu ou pas de symptômes d’anxiété au cours des deux dernières semaines.",
"mild": "Vos réponses indiquent des symptômes d’anxiété légers au cours des deux dernières semaines.",
"moderate": "Vos réponses indiquent des symptômes d’anxiété modérés au cours des deux dernières semaines.",
"severe": "Vos réponses indiquent des symptômes d’anxiété sévères au cours des deux dernières semaines."
},
"WHO5": {
"low": "Votre bien-être des deux dernières semaines était en dessous de la zone que le WHO-5 considère comme bonne.",
"good": "Votre bien-être des deux dernières semaines était dans la zone que le WHO-5 considère comme bonne."
},
"SCI": {
"belowThreshold": "Votre sommeil du dernier mois se situait dans la zone où l’étude à l’origine de ce questionnaire jugeait une insomnie probable.",
"aboveThreshold": "Votre sommeil du dernier mois se situait en dehors de la zone que cette étude associe à l’insomnie."
}
}
},
"records": {
@@ -11894,7 +11881,6 @@
},
"chat": {
"title": "Discuter de ce document",
"openAria": "Interroger le Coach sur ce document",
"openButton": "Demander au Coach"
},
"readingConsent": {
+87 -74
View File
@@ -1556,11 +1556,11 @@
"weekdays": {
"label": "Giorni della settimana",
"short": {
"mo": "Mo",
"mo": "Lu",
"tu": "Ma",
"we": "Me",
"th": "Gi",
"fr": "Fr",
"fr": "Ve",
"sa": "Sa",
"su": "Do"
},
@@ -3231,11 +3231,11 @@
"last30days": "{days} giorni",
"last90days": "{days} giorni",
"lastYear": "{months} mesi",
"allTime": "Tutto"
"allTime": "{months} mesi in dettaglio, di più per gli esami"
},
"detailDays": "Il Coach non legge nulla di più vecchio di {days} giorni, in nessuna risposta.",
"detailMonths": "Il Coach non legge nulla di più vecchio di {months} mesi, in nessuna risposta.",
"detailAll": "Tutto ciò che hai registrato: {dailyMonths} mesi giorno per giorno, le misurazioni più vecchie come valori mensili fino a {years} anni fa, gli esami degli ultimi {labMonths} mesi.",
"detailAll": "Senza limite, il Coach legge {dailyMonths} mesi di misurazioni giorno per giorno e quelle più vecchie come valori mensili fino a {years} anni fa. Degli esami legge l’ultimo valore dei marcatori misurati più di recente e, per un marcatore su cui chiedi, i suoi {labRecent} risultati precedenti; quelli più vecchi sono riassunti per anno fino a {labYears} anni fa, gli anni precedenti vengono solo contati.",
"note": "Il tuo profilo, i farmaci attuali, una malattia in corso e le prossime visite restano visibili; i riepiloghi che richiedono più storico vengono accorciati o omessi. Le risposte date in precedenza dal Coach nelle conversazioni più vecchie possono ancora citare valori più vecchi."
},
"showConversations": "Mostra conversazioni",
@@ -3599,7 +3599,7 @@
"headphoneAudioBody": "I livelli audio in cuffia rilevano in decibel quanto forte ascolti con auricolari o cuffie. Tenere basso il volume e fare pause protegge l'udito nel lungo periodo.",
"audioEventsBody": "Gli eventi sonori intensi segnalano i momenti in cui il rumore intorno a te ha raggiunto un livello che può danneggiare l'udito con esposizione sufficiente. Eventi frequenti sono un invito a cercare un ambiente più silenzioso o protezioni acustiche.",
"daylightBody": "Il tempo alla luce del giorno è quanto trascorri sotto la luce esterna ogni giorno. La luce diurna regolare sostiene un sano ritmo sonno-veglia e l'umore (CDC 2024), e il tempo all'aperto è collegato alla salute degli occhi.",
"bloodGlucoseBody": "La glicemia è la quantità di zucchero nel sangue, la principale fonte di energia del corpo. Sale dopo i pasti e si stabilizza tra un pasto e l'altro; valori che restano alti nel tempo possono indicare diabete o prediabete.",
"bloodGlucoseBody": "La glicemia è la quantità di zucchero nel sangue, la principale fonte di energia del corpo. Sale dopo i pasti e si stabilizza tra un pasto e l'altro. Come indicazione generale per gli adulti senza diabete, una glicemia a digiuno sotto {prediabetesFrom} {unit} è normale, tra {prediabetesFrom} e {prediabetesTo} indica prediabete, e da {diabetesFrom} in su rientra nell'intervallo del diabete (ADA 2024). Un singolo valore alto non è una diagnosi: un medico la conferma con un nuovo esame, e chi gestisce il diabete segue obiettivi diversi, stabiliti dal medico.",
"skinTemperatureBody": "La temperatura cutanea qui è la piccola variazione notturna della temperatura del polso rispetto al tuo valore di riferimento, non una misura assoluta della febbre. Le variazioni possono seguire sonno, ambiente, malattia o fase del ciclo mestruale.",
"cardioFitnessBody": "Il VO₂ max indica quanto ossigeno il tuo corpo riesce a utilizzare al massimo sforzo: il miglior indice di laboratorio della forma aerobica, che i wearable stimano in modo indiretto. Una tendenza più alta o in aumento è un forte segno di salute cardiovascolare.",
"cardioRecoveryBody": "Il recupero cardiaco è di quanti battiti al minuto cala la frequenza cardiaca nel primo minuto dopo aver interrotto uno sforzo intenso. Un calo maggiore significa che il cuore si calma più in fretta, segno di buona forma fisica.",
@@ -7431,6 +7431,7 @@
"lastReceived": "Ultima ricezione {relative}",
"lastNewValue": "Ultimo nuovo valore {relative}",
"lastBackground": "Ultima volta in background {relative}",
"lastDuplicate": "Ultimo reinvio senza dati nuovi {relative}",
"never": "Non ancora ricevuto",
"lastBackfill": "Storico recuperato {relative}",
"lastEnteredHere": "Ultimo inserimento qui {relative}"
@@ -10427,6 +10428,8 @@
"yellow-fever": "Febbre gialla",
"japanese-encephalitis": "Encefalite giapponese",
"cholera": "Colera",
"dengue": "Dengue",
"tuberculosis": "Tubercolosi",
"td": "Td (tetano, difterite)",
"tdap": "Tdap (tetano, difterite, pertosse)",
"tdap-ipv": "Tdap-IPV (tetravalente)",
@@ -10945,15 +10948,21 @@
"sci": "Test sul sonno, 8 domande"
},
"options": {
"0": "Per niente",
"0": "Mai",
"1": "Alcuni giorni",
"2": "Più della metà dei giorni",
"2": "Per più della metà dei giorni",
"3": "Quasi ogni giorno"
},
"functionalTitle": "Se hai indicato dei problemi, quanto ti hanno reso difficile lavorare, occuparti della casa o andare d'accordo con gli altri?",
"gad7Options": {
"0": "Mai",
"1": "Alcuni giorni",
"2": "Per oltre la metà dei giorni",
"3": "Quasi ogni giorno"
},
"functionalTitle": "Se ha fatto una crocetta su uno qualsiasi di questi problemi, quanto questi problemi le hanno reso difficile fare il suo lavoro, occuparsi delle sue cose a casa o avere buoni rapporti con gli altri?",
"functional": {
"0": "Per niente difficile",
"1": "Un po' difficile",
"1": "Abbastanza difficile",
"2": "Molto difficile",
"3": "Estremamente difficile"
},
@@ -10962,7 +10971,10 @@
"title": "Il tuo risultato",
"totalLabel": "Punteggio totale",
"bandLabel": "Intervallo per questo screening",
"takeAnother": "Fai un altro check-in"
"takeAnother": "Fai un altro check-in",
"scoreOf": "su {max}",
"firstCheckIn": "È il tuo primo risultato con questo questionario. Dal prossimo vedrai qui il cambiamento.",
"nextStep": "Puoi ripetere questo questionario tra due settimane o più tardi per seguire il cambiamento. Se qualcosa ti preoccupa, parlane in qualsiasi momento con un professionista sanitario."
},
"band": {
"PHQ9": {
@@ -11006,23 +11018,23 @@
"items": {
"phq9": {
"1": "Scarso interesse o piacere nel fare le cose",
"2": "Sentirsi giù di morale, depresso/a o disperato/a",
"3": "Difficoltà ad addormentarsi o a mantenere il sonno, oppure dormire troppo",
"2": "Sentirsi giù, triste o disperato/a",
"3": "Problemi ad addormentarsi o a dormire tutta la notte senza svegliarsi, o a dormire troppo",
"4": "Sentirsi stanco/a o avere poca energia",
"5": "Scarso appetito o mangiare troppo",
"6": "Avere una cattiva opinione di sé, o sentirsi un/una fallito/a o aver deluso se stesso/a o i propri familiari",
"7": "Difficoltà a concentrarsi, ad esempio nel leggere il giornale o nel guardare la televisione",
"8": "Muoversi o parlare così lentamente che altre persone potrebbero averlo notato? Oppure, al contrario, essere così agitato/a o irrequieto/a da muoversi molto più del solito",
"9": "Pensare che sarebbe meglio morire o di farsi del male in qualche modo"
"6": "Avere una scarsa opinione di sé, o sentirsi un/una fallito/a o aver deluso se stesso/a o i propri familiari",
"7": "Difficoltà a concentrarsi su qualcosa, per esempio leggere il giornale o guardare la televisione",
"8": "Muoversi o parlare così lentamente da poter essere notato/a da altre persone. O, al contrario, essere così irrequieto/a da muoversi molto più del solito",
"9": "Pensare che sarebbe meglio morire o farsi del male in un modo o nell’altro"
},
"gad7": {
"1": "Sentirsi nervoso/a, ansioso/a o teso/a",
"2": "Non riuscire a smettere di preoccuparsi o a controllare le preoccupazioni",
"3": "Preoccuparsi troppo per cose diverse",
"2": "Non riuscire a smettere di preoccuparsi o a tenere sotto controllo le preoccupazioni",
"3": "Preoccuparsi troppo per varie cose",
"4": "Avere difficoltà a rilassarsi",
"5": "Essere così irrequieto/a da far fatica a stare seduto/a fermo/a",
"5": "Essere talmente irrequieto/a da far fatica a stare seduto/a fermo/a",
"6": "Infastidirsi o irritarsi facilmente",
"7": "Avere paura come se potesse accadere qualcosa di terribile"
"7": "Avere paura che possa succedere qualcosa di terribile"
},
"who5": {
"1": "Mi sono sentito/a allegro/a e di buon umore",
@@ -11030,16 +11042,6 @@
"3": "Mi sono sentito/a attivo/a ed energico/a",
"4": "Mi sono svegliato/a sentendomi fresco/a e riposato/a",
"5": "La mia vita di tutti i giorni è stata piena di cose che mi interessano"
},
"sci": {
"1": "… how long does it take you to fall asleep?",
"2": "… if you then wake up during the night … how long are you awake for in total? (add all the wakenings up)",
"3": "… how many nights a week do you have a problem with your sleep?",
"4": "… how would you rate your sleep quality?",
"5": "… affected your mood, energy, or relationships?",
"6": "… affected your concentration, productivity, or ability to stay awake?",
"7": "… troubled you in general?",
"8": "… how long have you had a problem with your sleep?"
}
},
"crisisResource": {
@@ -11069,6 +11071,18 @@
},
"crisisTextLine": {
"name": "Crisis Text Line"
},
"cvv": {
"name": "CVV, Centro de Valorização da Vida (Brasile, 24/7, gratuito e riservato)"
},
"samu": {
"name": "SAMU, emergenza sanitaria (Brasile)"
},
"sns24": {
"name": "SNS 24, supporto psicologico (Portogallo, 24/7, opzione 4)"
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (Portogallo, tutti i giorni dalle 15:30 alle 0:30)"
}
},
"instrumentDescription": {
@@ -11077,7 +11091,6 @@
"who5": "Test sul benessere in 5 domande. Rispondi pensando alle ultime due settimane; numeri più alti indicano un maggior benessere.",
"sci": "Test sul sonno in 8 domande. La maggior parte riguarda l'ultimo mese; punteggi più alti indicano un sonno migliore."
},
"progress": "{current} / {total}",
"next": "Avanti",
"lastResult": "Ultimo test",
"lastScore": "Ultimo risultato",
@@ -11098,51 +11111,15 @@
"4": "La maggior parte del tempo",
"5": "Sempre"
},
"sciOptions": {
"duration": {
"0": "≥ 61 min",
"1": "46–60 min",
"2": "31–45 min",
"3": "16–30 min",
"4": "0–15 min"
},
"nights": {
"0": "5–7",
"1": "4",
"2": "3",
"3": "2",
"4": "0–1"
},
"quality": {
"0": "Very poor",
"1": "Poor",
"2": "Average",
"3": "Good",
"4": "Very good"
},
"impact": {
"0": "Very much",
"1": "Much",
"2": "Somewhat",
"3": "A little",
"4": "Not at all"
},
"problemDuration": {
"0": "> 1 year",
"1": "7–12 months",
"2": "3–6 months",
"3": "1–2 months",
"4": "I don't have a problem / < 1 month"
}
},
"stems": {
"phq9": {
"period": "Nelle ultime 2 settimane, con quale frequenza le ha dato fastidio ciascuno dei seguenti problemi?"
},
"gad7": {
"period": "Nelle ultime 2 settimane, con quale frequenza le ha dato fastidio ciascuno dei seguenti problemi?"
},
"who5": {
"period": "Nelle ultime due settimane"
},
"sci": {
"night": "Thinking about a typical night in the last month …",
"impact": "Thinking about the past month, to what extent has poor sleep …",
"finally": "Finally …"
}
},
"openDetail": "Mostra dettagli",
@@ -11157,6 +11134,43 @@
"worsened": "Raggiunge la variazione di {threshold} punti che gli studi considerano rilevante, nella direzione meno buona.",
"within_threshold": "È meno della variazione di {threshold} punti che gli studi considerano rilevante."
}
},
"wizard": {
"progressLabel": "Domanda {current} di {total}",
"followUpLabel": "Domanda aggiuntiva (facoltativa)",
"keyboardHint": "I tasti da 1 a {count} scelgono una risposta.",
"allAnswered": "Tutte le {total} domande hanno una risposta"
},
"review": {
"title": "Controlla le risposte",
"description": "Nulla viene salvato prima dell’invio, e ogni risposta si può ancora cambiare.",
"change": "Modifica",
"changeLabel": "Modifica la risposta alla domanda {number}",
"notAnswered": "Senza risposta",
"open": "Controlla le risposte"
},
"bandMeaning": {
"PHQ9": {
"minimal": "Le tue risposte indicano sintomi depressivi assenti o minimi nelle ultime due settimane.",
"mild": "Le tue risposte indicano sintomi depressivi lievi nelle ultime due settimane.",
"moderate": "Le tue risposte indicano sintomi depressivi moderati nelle ultime due settimane.",
"modSevere": "Le tue risposte indicano sintomi depressivi moderatamente gravi nelle ultime due settimane.",
"severe": "Le tue risposte indicano sintomi depressivi gravi nelle ultime due settimane."
},
"GAD7": {
"minimal": "Le tue risposte indicano sintomi d’ansia assenti o minimi nelle ultime due settimane.",
"mild": "Le tue risposte indicano sintomi d’ansia lievi nelle ultime due settimane.",
"moderate": "Le tue risposte indicano sintomi d’ansia moderati nelle ultime due settimane.",
"severe": "Le tue risposte indicano sintomi d’ansia gravi nelle ultime due settimane."
},
"WHO5": {
"low": "Il tuo benessere nelle ultime due settimane era sotto la fascia che il WHO-5 considera buona.",
"good": "Il tuo benessere nelle ultime due settimane era nella fascia che il WHO-5 considera buona."
},
"SCI": {
"belowThreshold": "Il tuo sonno nell’ultimo mese era nella fascia in cui lo studio alla base di questo questionario riteneva probabile un’insonnia.",
"aboveThreshold": "Il tuo sonno nell’ultimo mese era fuori dalla fascia che quello studio collega all’insonnia."
}
}
},
"records": {
@@ -11894,7 +11908,6 @@
},
"chat": {
"title": "Chatta su questo documento",
"openAria": "Chiedi al Coach su questo documento",
"openButton": "Chiedi al Coach"
},
"readingConsent": {
+60 -114
View File
@@ -3231,11 +3231,11 @@
"last30days": "{days}일",
"last90days": "{days}일",
"lastYear": "{months}개월",
"allTime": "전체"
"allTime": "{months}개월 상세, 검사 결과는 더 길게"
},
"detailDays": "코치는 어떤 답변에서도 {days}일보다 오래된 기록은 읽지 않아요.",
"detailMonths": "코치는 어떤 답변에서도 {months}개월보다 오래된 기록은 읽지 않아요.",
"detailAll": "기록한 모든 것: 최근 {dailyMonths}개월은 하루 단위로, 그 이전 측정값은 최대 {years}년 전까지 월별 값으로, 검사 결과는 최근 {labMonths}개월 치를 읽어요.",
"detailAll": "제한이 없으면 코치는 최근 {dailyMonths}개월은 하루 단위로, 그 이전 측정값은 최대 {years}년 전까지 월별 값으로 읽어요. 검사 결과는 가장 최근에 측정한 항목들의 최신 값을 읽고, 물어본 항목은 이전 결과 {labRecent}개도 읽어요. 더 오래된 결과는 최대 {labYears}년 전까지 연도별로 요약하고, 그보다 이전 연도는 개수만 세요.",
"note": "프로필, 현재 복용 중인 약, 진행 중인 질병, 예정된 진료는 계속 보여요. 더 긴 기록이 필요한 요약은 줄이거나 빼요. 예전 대화에서 코치가 이미 한 답변에는 더 오래된 값이 남아 있을 수 있어요."
},
"showConversations": "대화 보기",
@@ -7431,6 +7431,7 @@
"lastReceived": "마지막 수신 {relative}",
"lastNewValue": "마지막 새 값 {relative}",
"lastBackground": "마지막 백그라운드 수신 {relative}",
"lastDuplicate": "새 값 없이 마지막 재전송 {relative}",
"never": "아직 수신되지 않음",
"lastBackfill": "기록 보충 {relative}",
"lastEnteredHere": "여기에서 마지막 입력 {relative}"
@@ -10427,6 +10428,8 @@
"yellow-fever": "황열",
"japanese-encephalitis": "일본뇌염",
"cholera": "콜레라",
"dengue": "뎅기열",
"tuberculosis": "결핵",
"td": "Td(파상풍, 디프테리아)",
"tdap": "Tdap(파상풍, 디프테리아, 백일해)",
"tdap-ipv": "Tdap-IPV(4종 혼합)",
@@ -10944,25 +10947,15 @@
"who5": "웰빙 점검, 5문항",
"sci": "수면 점검, 8문항"
},
"options": {
"0": "전혀 방해 받지 않았다",
"1": "며칠 동안 방해 받았다",
"2": "7 일 이상 방해 받았다",
"3": "거의 매일 방해 받았다"
},
"functionalTitle": "만일 당신이 위의 문제 중 하나 이상 \"예\" 라고 응답하셨으면, 이러한 문제들로 인해서 당신은 일을 하거나 가정일을 돌보거나 다른 사람과 어울리는 것이 얼마나 어려웠습니까?",
"functional": {
"0": "전혀 어렵지 않았다",
"1": "약간 어려웠다",
"2": "많이 어려웠다",
"3": "매우 많이 어려웠다"
},
"functionalOptionalHint": "선택 사항이에요. 이 문항에 답하지 않아도 제출할 수 있어요.",
"result": {
"title": "내 결과",
"totalLabel": "총점",
"bandLabel": "이 검사의 구간",
"takeAnother": "다른 점검 하기"
"takeAnother": "다른 점검 하기",
"scoreOf": "/ {max}",
"firstCheckIn": "이 설문의 첫 결과예요. 다음 결과부터 여기에 변화가 표시돼요.",
"nextStep": "변화를 보려면 2주 뒤나 그 이후에 다시 해 볼 수 있어요. 걱정되는 점이 있으면 언제든 전문가와 상담하세요."
},
"band": {
"PHQ9": {
@@ -11003,45 +10996,6 @@
"ifDanger": "당장 위험한 상황이라면 지금 {emergency}로 전화하세요.",
"resourcesTitle": "도움을 줄 수 있는 곳"
},
"items": {
"phq9": {
"1": "일 또는 여가 활동을 하는 데 흥미나 즐거움을 느끼지 못함",
"2": "기분이 가라앉거나, 우울하거나, 희망이 없음",
"3": "잠이 들거나 계속 잠을 자는 것이 어려움, 또는 잠을 너무 많이 잠",
"4": "피곤하다고 느끼거나 기운이 거의 없음",
"5": "입맛이 없거나 과식을 함",
"6": "자신을 부정적으로 봄 — 혹은 자신이 실패자라고 느끼거나 자신 또는 가족을 실망시킴",
"7": "신문을 읽거나 텔레비전 보는 것과 같은 일에 집중하는 것이 어려움",
"8": "다른 사람들이 주목할 정도로 너무 느리게 움직이거나 말을 함. 또는 반대로 평상시보다 많이 움직여서, 너무 안절부절 못하거나 들떠 있음",
"9": "자신이 죽는 것이 더 낫다고 생각하거나 어떤 식으로든 자신을 해칠 것이라고 생각함"
},
"gad7": {
"1": "초조하거나 불안하거나 조마조마하게 느낀다",
"2": "걱정하는 것을 멈추거나 조절할 수가 없다",
"3": "여러 가지 것들에 대해 걱정을 너무 많이 한다",
"4": "편하게 있기가 어렵다",
"5": "너무 안절부절못해서 가만히 있기가 힘들다",
"6": "쉽게 짜증이 나거나 쉽게 성을 내게 된다",
"7": "마치 끔찍한 일이 생길 것처럼 두렵게 느껴진다"
},
"who5": {
"1": "나는 즐겁고 기분이 좋다.",
"2": "나는 평온하고 느긋하다.",
"3": "나는 의욕적이고 활기차다.",
"4": "나는 자고 일어나면 상쾌하고 푹 쉬었다는 느낌이 든다.",
"5": "나의 일상생활은 흥미로운 일들로 가득하다."
},
"sci": {
"1": "… how long does it take you to fall asleep?",
"2": "… if you then wake up during the night … how long are you awake for in total? (add all the wakenings up)",
"3": "… how many nights a week do you have a problem with your sleep?",
"4": "… how would you rate your sleep quality?",
"5": "… affected your mood, energy, or relationships?",
"6": "… affected your concentration, productivity, or ability to stay awake?",
"7": "… troubled you in general?",
"8": "… how long have you had a problem with your sleep?"
}
},
"crisisResource": {
"telefonSeelsorge": {
"name": "TelefonSeelsorge (24시간 연중무휴, 무료, 비밀 보장)"
@@ -11069,6 +11023,18 @@
},
"crisisTextLine": {
"name": "Crisis Text Line"
},
"cvv": {
"name": "CVV, Centro de Valorização da Vida (브라질, 24시간 연중무휴, 무료, 비밀 보장)"
},
"samu": {
"name": "SAMU 응급의료 서비스 (브라질)"
},
"sns24": {
"name": "SNS 24 심리 상담 (포르투갈, 24시간 연중무휴, 4번 선택)"
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (포르투갈, 매일 15:30~00:30)"
}
},
"instrumentDescription": {
@@ -11077,7 +11043,6 @@
"who5": "5문항 웰빙 점검이에요. 지난 2주를 기준으로 답해 주세요. 숫자가 클수록 웰빙이 좋아요.",
"sci": "8문항 수면 점검이에요. 대부분 지난 한 달을 묻고, 점수가 높을수록 수면이 좋아요."
},
"progress": "{current} / {total}",
"next": "다음",
"lastResult": "최근 검사",
"lastScore": "최근 결과",
@@ -11090,61 +11055,6 @@
"sci": "이 설문의 바탕이 된 연구에서는 16점 이하가 불면증 가능성과 관련이 있었어요. 수면 문제가 계속 힘들게 한다면 전문가와 이야기해 보는 게 도움이 될 수 있어요. 진단은 아니에요."
},
"validatedInEnglishNote": "이 설문은 영어로 검증된 도구예요. 문항은 원문 영어 표현 그대로 보여줘요.",
"who5Options": {
"0": "전혀 그렇지 않다.",
"1": "가끔 그렇다.",
"2": "절반 미만이 그렇다.",
"3": "절반 이상이 그렇다.",
"4": "거의 그렇다.",
"5": "항상 그렇다."
},
"sciOptions": {
"duration": {
"0": "≥ 61 min",
"1": "46–60 min",
"2": "31–45 min",
"3": "16–30 min",
"4": "0–15 min"
},
"nights": {
"0": "5–7",
"1": "4",
"2": "3",
"3": "2",
"4": "0–1"
},
"quality": {
"0": "Very poor",
"1": "Poor",
"2": "Average",
"3": "Good",
"4": "Very good"
},
"impact": {
"0": "Very much",
"1": "Much",
"2": "Somewhat",
"3": "A little",
"4": "Not at all"
},
"problemDuration": {
"0": "> 1 year",
"1": "7–12 months",
"2": "3–6 months",
"3": "1–2 months",
"4": "I don't have a problem / < 1 month"
}
},
"stems": {
"who5": {
"period": "지난 2주 동안"
},
"sci": {
"night": "Thinking about a typical night in the last month …",
"impact": "Thinking about the past month, to what extent has poor sleep …",
"finally": "Finally …"
}
},
"openDetail": "자세히 보기",
"historyLoadError": "검사 기록을 불러오지 못했어요",
"historyLoadErrorHint": "결과가 없다는 뜻은 아니에요. 목록을 가져오지 못했을 뿐이에요. 다시 시도해 주세요.",
@@ -11157,6 +11067,43 @@
"worsened": "연구에서 의미 있다고 보는 {threshold}점 변화에 도달했고, 덜 좋은 방향입니다.",
"within_threshold": "연구에서 의미 있다고 보는 {threshold}점 변화보다 작습니다."
}
},
"wizard": {
"progressLabel": "{total}개 중 {current}번째 질문",
"followUpLabel": "추가 질문(선택)",
"keyboardHint": "숫자 키 1부터 {count}까지로 답을 고를 수 있어요.",
"allAnswered": "{total}개 질문에 모두 답했어요"
},
"review": {
"title": "답변 확인",
"description": "제출하기 전에는 아무것도 저장되지 않으며, 모든 답변을 아직 바꿀 수 있어요.",
"change": "변경",
"changeLabel": "{number}번 질문의 답변 변경",
"notAnswered": "답하지 않음",
"open": "답변 확인"
},
"bandMeaning": {
"PHQ9": {
"minimal": "지난 2주 동안 우울 증상이 거의 또는 전혀 없었던 것으로 보여요.",
"mild": "지난 2주 동안 가벼운 우울 증상이 있었던 것으로 보여요.",
"moderate": "지난 2주 동안 중간 정도의 우울 증상이 있었던 것으로 보여요.",
"modSevere": "지난 2주 동안 다소 심한 우울 증상이 있었던 것으로 보여요.",
"severe": "지난 2주 동안 심한 우울 증상이 있었던 것으로 보여요."
},
"GAD7": {
"minimal": "지난 2주 동안 불안 증상이 거의 또는 전혀 없었던 것으로 보여요.",
"mild": "지난 2주 동안 가벼운 불안 증상이 있었던 것으로 보여요.",
"moderate": "지난 2주 동안 중간 정도의 불안 증상이 있었던 것으로 보여요.",
"severe": "지난 2주 동안 심한 불안 증상이 있었던 것으로 보여요."
},
"WHO5": {
"low": "지난 2주 동안의 웰빙이 WHO-5가 좋다고 보는 범위보다 낮았어요.",
"good": "지난 2주 동안의 웰빙이 WHO-5가 좋다고 보는 범위에 있었어요."
},
"SCI": {
"belowThreshold": "지난 한 달 동안의 수면이 이 설문의 바탕이 된 연구에서 불면증 가능성이 높다고 본 범위에 있었어요.",
"aboveThreshold": "지난 한 달 동안의 수면이 그 연구가 불면증과 연결한 범위 밖에 있었어요."
}
}
},
"records": {
@@ -11894,7 +11841,6 @@
},
"chat": {
"title": "이 문서에 대해 대화",
"openAria": "이 문서에 대해 코치에게 묻기",
"openButton": "코치에게 묻기"
},
"readingConsent": {
@@ -13438,7 +13384,7 @@
"empty": "{from}~{to} 기록 없음",
"emptySingle": "{period} 기록 없음",
"ended": "{label} 끝남",
"afterDaysOne": "{count}일 후",
"afterDaysOne": "1일 후",
"afterDaysFew": "{count}일 후",
"afterDaysOther": "{count}일 후",
"notable": {
@@ -13462,7 +13408,7 @@
"apply": "적용"
},
"item": {
"labValuesOne": "검사 수치 {count}개",
"labValuesOne": "검사 수치 1개",
"labValuesFew": "검사 수치 {count}개",
"labValuesOther": "검사 수치 {count}개"
}
+58 -112
View File
@@ -3231,11 +3231,11 @@
"last30days": "{days} dagen",
"last90days": "{days} dagen",
"lastYear": "{months} maanden",
"allTime": "Alles"
"allTime": "{months} maanden in detail, labuitslagen langer"
},
"detailDays": "De Coach leest in geen enkel antwoord iets dat ouder is dan {days} dagen.",
"detailMonths": "De Coach leest in geen enkel antwoord iets dat ouder is dan {months} maanden.",
"detailAll": "Alles wat je hebt vastgelegd: {dailyMonths} maanden per dag, oudere metingen als maandwaarden tot {years} jaar terug en labuitslagen van de afgelopen {labMonths} maanden.",
"detailAll": "Zonder grens leest de Coach {dailyMonths} maanden aan metingen per dag en oudere metingen als maandwaarden tot {years} jaar terug. Van labuitslagen leest hij de laatste waarde van de meest recent gemeten markers en, voor een marker waar je naar vraagt, ook de {labRecent} vorige uitslagen; oudere worden per jaar samengevat tot {labYears} jaar terug, nog oudere jaren alleen geteld.",
"note": "Je profiel, je huidige medicatie, een lopende ziekte en komende afspraken blijven zichtbaar; samenvattingen die meer geschiedenis nodig hebben, worden ingekort of weggelaten. Antwoorden die de Coach eerder in oudere gesprekken gaf, kunnen nog oudere waarden noemen."
},
"showConversations": "Gesprekken tonen",
@@ -7431,6 +7431,7 @@
"lastReceived": "Laatst ontvangen {relative}",
"lastNewValue": "Laatste nieuwe waarde {relative}",
"lastBackground": "Laatst op de achtergrond {relative}",
"lastDuplicate": "Laatst opnieuw verzonden zonder nieuwe waarden {relative}",
"never": "Nog niet ontvangen",
"lastBackfill": "Geschiedenis bijgewerkt {relative}",
"lastEnteredHere": "Laatst hier ingevoerd {relative}"
@@ -10427,6 +10428,8 @@
"yellow-fever": "Gele koorts",
"japanese-encephalitis": "Japanse encefalitis",
"cholera": "Cholera",
"dengue": "Dengue",
"tuberculosis": "Tuberculose",
"td": "Td (tetanus, difterie)",
"tdap": "Tdap (tetanus, difterie, kinkhoest)",
"tdap-ipv": "Tdap-IPV (vier-in-één)",
@@ -10944,25 +10947,15 @@
"who5": "Welzijnscheck, 5 vragen",
"sci": "Slaapcheck, 8 vragen"
},
"options": {
"0": "Helemaal niet",
"1": "Meerdere dagen",
"2": "Meer dan de helft van de dagen",
"3": "Bijna elke dag"
},
"functionalTitle": "Als je een of meer problemen hebt aangevinkt: hoe moeilijk was het door deze problemen om je werk te doen, voor je huishouden te zorgen of met andere mensen om te gaan?",
"functional": {
"0": "Helemaal niet moeilijk",
"1": "Enigszins moeilijk",
"2": "Erg moeilijk",
"3": "Extreem moeilijk"
},
"functionalOptionalHint": "Optioneel: je kunt ook versturen zonder deze vraag te beantwoorden.",
"result": {
"title": "Je resultaat",
"totalLabel": "Totaalscore",
"bandLabel": "Bereik voor deze screening",
"takeAnother": "Nog een check-in doen"
"takeAnother": "Nog een check-in doen",
"scoreOf": "van {max}",
"firstCheckIn": "Dit is je eerste uitslag voor deze vragenlijst. Vanaf de volgende zie je hier de verandering.",
"nextStep": "Je kunt deze check-in over twee weken of later herhalen om de verandering te volgen. Maak je je ergens zorgen over, praat dan op elk moment met een zorgverlener."
},
"band": {
"PHQ9": {
@@ -11003,45 +10996,6 @@
"ifDanger": "Ben je in direct gevaar? Bel dan nu {emergency}.",
"resourcesTitle": "Mensen die je kunnen helpen"
},
"items": {
"phq9": {
"1": "Weinig interesse in of plezier aan activiteiten",
"2": "Zich neerslachtig, depressief of hopeloos voelen",
"3": "Moeilijk in slaap vallen of doorslapen, of te veel slapen",
"4": "Zich moe voelen of weinig energie hebben",
"5": "Weinig eetlust of overmatig eten",
"6": "Een negatief beeld van uzelf hebben, of het gevoel hebben dat u een mislukkeling bent of dat u uzelf of uw familie teleurgesteld hebt",
"7": "Moeite met concentreren, bijvoorbeeld bij het lezen van de krant of het televisiekijken",
"8": "Zo traag bewegen of praten dat andere mensen het zouden kunnen opmerken. Of juist het tegenovergestelde: zo gejaagd of rusteloos zijn dat u veel meer beweegt dan gewoonlijk",
"9": "Gedachten dat u beter dood zou kunnen zijn of gedachten om uzelf op een of andere manier pijn te doen"
},
"gad7": {
"1": "Zich nerveus, angstig of gespannen voelen",
"2": "Niet in staat zijn om te stoppen met piekeren of om het piekeren onder controle te houden",
"3": "Zich te veel zorgen maken over verschillende dingen",
"4": "Moeite met ontspannen",
"5": "Zo rusteloos zijn dat het moeilijk is om stil te zitten",
"6": "Snel geërgerd of geïrriteerd zijn",
"7": "Bang zijn dat er iets vreselijks zou kunnen gebeuren"
},
"who5": {
"1": "… voelde ik me opgewekt en goedgehumeurd",
"2": "… voelde ik me rustig en ontspannen",
"3": "… voelde ik me actief en energiek",
"4": "… voelde ik me fris en uitgerust bij het wakker worden",
"5": "… was mijn dagelijks leven gevuld met dingen die mij interesseren"
},
"sci": {
"1": "… how long does it take you to fall asleep?",
"2": "… if you then wake up during the night … how long are you awake for in total? (add all the wakenings up)",
"3": "… how many nights a week do you have a problem with your sleep?",
"4": "… how would you rate your sleep quality?",
"5": "… affected your mood, energy, or relationships?",
"6": "… affected your concentration, productivity, or ability to stay awake?",
"7": "… troubled you in general?",
"8": "… how long have you had a problem with your sleep?"
}
},
"crisisResource": {
"telefonSeelsorge": {
"name": "TelefonSeelsorge (dag en nacht, gratis, vertrouwelijk)"
@@ -11069,6 +11023,18 @@
},
"crisisTextLine": {
"name": "Crisis Text Line"
},
"cvv": {
"name": "CVV, Centro de Valorização da Vida (Brazilië, dag en nacht, gratis, vertrouwelijk)"
},
"samu": {
"name": "SAMU, medische spoedhulp (Brazilië)"
},
"sns24": {
"name": "SNS 24, psychologische hulp (Portugal, dag en nacht, keuze 4)"
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (Portugal, dagelijks van 15.30 tot 0.30 uur)"
}
},
"instrumentDescription": {
@@ -11077,7 +11043,6 @@
"who5": "Welzijnscheck met 5 vragen. Beantwoord ze voor de afgelopen twee weken; hogere getallen betekenen een beter welzijn.",
"sci": "Slaapcheck met 8 vragen. De meeste vragen gaan over de afgelopen maand; hogere scores betekenen een betere slaap."
},
"progress": "{current} / {total}",
"next": "Volgende",
"lastResult": "Laatste test",
"lastScore": "Laatste resultaat",
@@ -11090,61 +11055,6 @@
"sci": "In het onderzoek achter deze vragenlijst hingen scores van 16 of lager samen met waarschijnlijke slapeloosheid. Blijf je last houden van slecht slapen, dan kan een gesprek met een zorgverlener helpen. Dit is geen diagnose."
},
"validatedInEnglishNote": "Deze vragenlijst is in het Engels gevalideerd; de vragen worden in de oorspronkelijke Engelse formulering getoond.",
"who5Options": {
"0": "Nooit",
"1": "Af en toe",
"2": "Minder dan de helft van de tijd",
"3": "Meer dan de helft van de tijd",
"4": "Meestal",
"5": "Altijd"
},
"sciOptions": {
"duration": {
"0": "≥ 61 min",
"1": "46–60 min",
"2": "31–45 min",
"3": "16–30 min",
"4": "0–15 min"
},
"nights": {
"0": "5–7",
"1": "4",
"2": "3",
"3": "2",
"4": "0–1"
},
"quality": {
"0": "Very poor",
"1": "Poor",
"2": "Average",
"3": "Good",
"4": "Very good"
},
"impact": {
"0": "Very much",
"1": "Much",
"2": "Somewhat",
"3": "A little",
"4": "Not at all"
},
"problemDuration": {
"0": "> 1 year",
"1": "7–12 months",
"2": "3–6 months",
"3": "1–2 months",
"4": "I don't have a problem / < 1 month"
}
},
"stems": {
"who5": {
"period": "In de afgelopen twee weken …"
},
"sci": {
"night": "Thinking about a typical night in the last month …",
"impact": "Thinking about the past month, to what extent has poor sleep …",
"finally": "Finally …"
}
},
"openDetail": "Details tonen",
"historyLoadError": "Je testgeschiedenis kon niet worden geladen",
"historyLoadErrorHint": "Dit betekent niet dat je geen resultaten hebt; de lijst kon niet worden opgehaald. Probeer het opnieuw.",
@@ -11157,6 +11067,43 @@
"worsened": "Dat bereikt de verandering van {threshold} punten die studies als betekenisvol zien, in de minder goede richting.",
"within_threshold": "Dat is minder dan de verandering van {threshold} punten die studies als betekenisvol zien."
}
},
"wizard": {
"progressLabel": "Vraag {current} van {total}",
"followUpLabel": "Aanvullende vraag (niet verplicht)",
"keyboardHint": "Met de cijfertoetsen 1 tot en met {count} kies je een antwoord.",
"allAnswered": "Alle {total} vragen beantwoord"
},
"review": {
"title": "Antwoorden controleren",
"description": "Er wordt niets opgeslagen voordat je verstuurt, en elk antwoord kun je nog wijzigen.",
"change": "Wijzigen",
"changeLabel": "Antwoord op vraag {number} wijzigen",
"notAnswered": "Niet beantwoord",
"open": "Antwoorden controleren"
},
"bandMeaning": {
"PHQ9": {
"minimal": "Je antwoorden wijzen op weinig of geen depressieve klachten in de afgelopen twee weken.",
"mild": "Je antwoorden wijzen op lichte depressieve klachten in de afgelopen twee weken.",
"moderate": "Je antwoorden wijzen op matige depressieve klachten in de afgelopen twee weken.",
"modSevere": "Je antwoorden wijzen op matig ernstige depressieve klachten in de afgelopen twee weken.",
"severe": "Je antwoorden wijzen op ernstige depressieve klachten in de afgelopen twee weken."
},
"GAD7": {
"minimal": "Je antwoorden wijzen op weinig of geen angstklachten in de afgelopen twee weken.",
"mild": "Je antwoorden wijzen op lichte angstklachten in de afgelopen twee weken.",
"moderate": "Je antwoorden wijzen op matige angstklachten in de afgelopen twee weken.",
"severe": "Je antwoorden wijzen op ernstige angstklachten in de afgelopen twee weken."
},
"WHO5": {
"low": "Je welzijn in de afgelopen twee weken lag onder het bereik dat de WHO-5 als goed beschouwt.",
"good": "Je welzijn in de afgelopen twee weken lag in het bereik dat de WHO-5 als goed beschouwt."
},
"SCI": {
"belowThreshold": "Je slaap in de afgelopen maand lag in het bereik waarin het onderzoek achter deze vragenlijst slapeloosheid waarschijnlijk vond.",
"aboveThreshold": "Je slaap in de afgelopen maand lag buiten het bereik dat dat onderzoek met slapeloosheid verbindt."
}
}
},
"records": {
@@ -11894,7 +11841,6 @@
},
"chat": {
"title": "Chatten over dit document",
"openAria": "De Coach een vraag stellen over dit document",
"openButton": "Vraag het de Coach"
},
"readingConsent": {
+95 -82
View File
@@ -1556,13 +1556,13 @@
"weekdays": {
"label": "Dni tygodnia",
"short": {
"mo": "Mo",
"tu": "Wt",
"we": "Śr",
"th": "Cz",
"fr": "Fr",
"sa": "Sa",
"su": "Nd"
"mo": "Pon",
"tu": "Wto",
"we": "Śro",
"th": "Czw",
"fr": "Pią",
"sa": "Sob",
"su": "Nie"
},
"long": {
"mo": "Poniedziałek",
@@ -3231,11 +3231,11 @@
"last30days": "{days} dni",
"last90days": "{days} dni",
"lastYear": "{months} miesięcy",
"allTime": "Wszystko"
"allTime": "{months} miesięcy szczegółowo, wyniki badań dłużej"
},
"detailDays": "Coach nie czyta niczego starszego niż {days} dni, w żadnej odpowiedzi.",
"detailMonths": "Coach nie czyta niczego starszego niż {months} miesięcy, w żadnej odpowiedzi.",
"detailAll": "Wszystko, co zostało zapisane: {dailyMonths} miesięcy z dokładnością do dnia, starsze pomiary jako wartości miesięczne do {years} lat wstecz, wyniki badań z ostatnich {labMonths} miesięcy.",
"detailAll": "Bez limitu Coach czyta {dailyMonths} miesięcy pomiarów z dokładnością do dnia, a starsze pomiary jako wartości miesięczne do {years} lat wstecz. Z wyników badań czyta ostatnią wartość ostatnio mierzonych markerów, a dla markera, o który pytasz, także {labRecent} poprzednich wyników; starsze są podsumowane rocznie do {labYears} lat wstecz, a wcześniejsze lata tylko zliczane.",
"note": "Twój profil, obecne leki, trwająca choroba i nadchodzące wizyty pozostają widoczne; podsumowania wymagające dłuższej historii są skracane lub pomijane. Odpowiedzi, których Coach udzielił wcześniej w starszych rozmowach, mogą nadal przytaczać starsze wartości."
},
"showConversations": "Pokaż rozmowy",
@@ -3599,7 +3599,7 @@
"headphoneAudioBody": "Poziom dźwięku w słuchawkach śledzi w decybelach, jak głośno słuchasz przez słuchawki. Ściszanie głośności i robienie przerw chroni słuch w dłuższej perspektywie.",
"audioEventsBody": "Zdarzenia głośnego dźwięku oznaczają momenty, gdy hałas wokół ciebie osiągnął poziom mogący przy wystarczającej ekspozycji uszkodzić słuch. Częste zdarzenia to sygnał, by poszukać cichszego otoczenia lub ochrony słuchu.",
"daylightBody": "Czas w świetle dziennym to ilość czasu spędzanego w świetle na zewnątrz każdego dnia. Regularne światło dzienne wspiera zdrowy rytm snu i czuwania oraz nastrój (CDC 2024), a czas na świeżym powietrzu wiąże się ze zdrowiem oczu.",
"bloodGlucoseBody": "Poziom glukozy we krwi to ilość cukru we krwi, głównego źródła energii organizmu. Rośnie po jedzeniu i ustala się między posiłkami; wartości utrzymujące się wysoko mogą sygnalizować cukrzycę lub stan przedcukrzycowy.",
"bloodGlucoseBody": "Poziom glukozy we krwi to ilość cukru we krwi, głównego źródła energii organizmu. Rośnie po jedzeniu i ustala się między posiłkami. Jako ogólna wskazówka dla dorosłych bez cukrzycy: wynik na czczo poniżej {prediabetesFrom} {unit} jest prawidłowy, {prediabetesFrom}–{prediabetesTo} oznacza stan przedcukrzycowy, a {diabetesFrom} lub więcej mieści się w zakresie cukrzycy (ADA 2024). Pojedynczy wysoki wynik nie jest diagnozą; lekarz potwierdza ją powtórnym badaniem, a osoby leczące cukrzycę mają inne cele, ustalone przez lekarza.",
"skinTemperatureBody": "Temperatura skóry to tutaj niewielka nocna zmiana temperatury nadgarstka względem twojej własnej wartości bazowej, a nie bezwzględny pomiar gorączki. Zmiany mogą odzwierciedlać sen, otoczenie, chorobę lub fazę cyklu menstruacyjnego.",
"cardioFitnessBody": "VO₂ max to ilość tlenu, jaką organizm potrafi wykorzystać przy maksymalnym wysiłku. To najlepszy laboratoryjny wskaźnik wydolności tlenowej, który urządzenia noszone szacują pośrednio. Wyższy lub rosnący trend to mocny sygnał dobrego zdrowia układu krążenia.",
"cardioRecoveryBody": "Regeneracja serca to liczba uderzeń na minutę, o jaką spada tętno w pierwszej minucie po zakończeniu intensywnego wysiłku. Większy spadek oznacza, że serce szybciej się uspokaja, co świadczy o dobrej kondycji.",
@@ -7431,6 +7431,7 @@
"lastReceived": "Ostatnio odebrano {relative}",
"lastNewValue": "Ostatnia nowa wartość {relative}",
"lastBackground": "Ostatnio w tle {relative}",
"lastDuplicate": "Ostatnio wysłano ponownie bez nowych wartości {relative}",
"never": "Jeszcze nie odebrano",
"lastBackfill": "Historia uzupełniona {relative}",
"lastEnteredHere": "Ostatni wpis tutaj {relative}"
@@ -10427,6 +10428,8 @@
"yellow-fever": "Żółta gorączka",
"japanese-encephalitis": "Japońskie zapalenie mózgu",
"cholera": "Cholera",
"dengue": "Denga",
"tuberculosis": "Gruźlica",
"td": "Td (tężec, błonica)",
"tdap": "Tdap (tężec, błonica, krztusiec)",
"tdap-ipv": "Tdap-IPV (poczwórna)",
@@ -10945,24 +10948,33 @@
"sci": "Test snu, 8 pytań"
},
"options": {
"0": "Wcale",
"0": "Wcale nie dokuczały",
"1": "Kilka dni",
"2": "Więcej niż połowę dni",
"3": "Niemal codziennie"
},
"functionalTitle": "Jeśli zaznaczyłeś(-aś) jakiekolwiek problemy, na ile utrudniły Ci one pracę, prowadzenie domu lub kontakty z innymi ludźmi?",
"gad7Options": {
"0": "Wcale nie dokuczały",
"1": "Kilka dni",
"2": "Więcej niż połowę dni",
"3": "Niemal codziennie"
},
"functionalTitle": "Jeżeli zaznaczył/-a Pan/Pani którekolwiek z problemów, jak bardzo utrudniły one Panu/Pani wykonywanie pracy, zajmowanie się domem lub relacje z innymi ludźmi?",
"functional": {
"0": "Wcale nie utrudniły",
"0": "W ogóle nie utrudniły",
"1": "Trochę utrudniły",
"2": "Bardzo utrudniły",
"3": "Skrajnie utrudniły"
"3": "Niezmiernie utrudniły"
},
"functionalOptionalHint": "Opcjonalne: możesz wysłać bez odpowiedzi na to pytanie.",
"result": {
"title": "Twój wynik",
"totalLabel": "Wynik łączny",
"bandLabel": "Zakres dla tego przesiewu",
"takeAnother": "Wykonaj kolejne sprawdzenie"
"takeAnother": "Wykonaj kolejne sprawdzenie",
"scoreOf": "z {max}",
"firstCheckIn": "To Twój pierwszy wynik w tym kwestionariuszu. Od następnego zobaczysz tu zmianę.",
"nextStep": "Możesz powtórzyć ten kwestionariusz za dwa tygodnie lub później, aby śledzić zmianę. Jeśli coś Cię niepokoi, porozmawiaj w każdej chwili z profesjonalistą."
},
"band": {
"PHQ9": {
@@ -11006,23 +11018,23 @@
"items": {
"phq9": {
"1": "Niewielkie zainteresowanie lub odczuwanie przyjemności z wykonywania czynności",
"2": "Uczucie przygnębienia, depresji lub beznadziejności",
"3": "Kłopoty z zaśnięciem lub utrzymaniem snu albo zbyt długi sen",
"2": "Uczucie smutku, przygnębienia lub beznadziejności",
"3": "Kłopoty z zaśnięciem lub przerywany sen, albo zbyt długi sen",
"4": "Uczucie zmęczenia lub brak energii",
"5": "Brak apetytu lub przejadanie się",
"6": "Złe myślenie o sobie — poczucie, że jest się nieudacznikiem lub że zawiodło się siebie albo rodzinę",
"7": "Trudności ze skupieniem się, na przykład podczas czytania gazety lub oglądania telewizji",
"8": "Poruszanie się lub mówienie tak wolno, że inni mogli to zauważyć? Albo przeciwnie — niepokój lub pobudzenie powodujące, że poruszasz się znacznie więcej niż zwykle",
"9": "Myśli, że lepiej byłoby umrzeć albo chęć zrobienia sobie w jakiś sposób krzywdy"
"6": "Poczucie niezadowolenia z siebie — lub uczucie, że jest się do niczego, albo że zawiódł/zawiodła Pan/Pani siebie lub rodzinę",
"7": "Problemy ze skupieniem się na przykład przy czytaniu gazety lub oglądaniu telewizji",
"8": "Poruszanie się lub mówienie tak wolno, że inni mogliby to zauważyć? Albo wręcz przeciwnie — niemożność usiedzenia w miejscu lub podenerwowanie powodujące ruchliwość znacznie większą niż zwykle",
"9": "Myśli, że lepiej byłoby umrzeć, albo chęć zrobienia sobie jakiejś krzywdy"
},
"gad7": {
"1": "Odczuwanie zdenerwowania, lęku lub napięcia",
"2": "Niemożność przerwania lub kontrolowania zamartwiania się",
"3": "Zbytnie zamartwianie się różnymi sprawami",
"4": "Trudności z odprężeniem się",
"5": "Niepokój tak duży, że trudno usiedzieć w miejscu",
"6": "Łatwe wpadanie w rozdrażnienie lub irytację",
"7": "Odczuwanie lęku, jakby miało się zdarzyć coś strasznego"
"1": "Czuł(a) się Pan(i) podenerwowany(a), niespokojny(a), mocno spięty(a)",
"2": "Nie mógł(a) Pan(i) przestać się martwić albo zapanować nad tym",
"3": "Za bardzo się Pan(i) martwił(a) różnymi rzeczami",
"4": "Miał(a) Pan(i) trudności z relaksowaniem się",
"5": "Był(a) Pan(i) tak niespokojny(a), że nie mógł(a) usiedzieć na miejscu",
"6": "Łatwo stawał(a) się Pan(i) rozdrażniony(a) lub poirytowany(a)",
"7": "Obawiał(a) się Pan(i), tak jakby miało się stać coś strasznego"
},
"who5": {
"1": "Czułam się wesoła/Czułem się wesoły i w dobrym nastroju",
@@ -11030,16 +11042,6 @@
"3": "Czułam się aktywna i energiczna/Czułem się aktywny i energiczny",
"4": "Budziłam się z uczuciem świeżości i wypoczęta/Budziłem się z uczuciem świeżości i wypoczęty",
"5": "Moje życie codzienne było wypełnione interesującymi mnie sprawami"
},
"sci": {
"1": "… how long does it take you to fall asleep?",
"2": "… if you then wake up during the night … how long are you awake for in total? (add all the wakenings up)",
"3": "… how many nights a week do you have a problem with your sleep?",
"4": "… how would you rate your sleep quality?",
"5": "… affected your mood, energy, or relationships?",
"6": "… affected your concentration, productivity, or ability to stay awake?",
"7": "… troubled you in general?",
"8": "… how long have you had a problem with your sleep?"
}
},
"crisisResource": {
@@ -11069,6 +11071,18 @@
},
"crisisTextLine": {
"name": "Crisis Text Line"
},
"cvv": {
"name": "CVV, Centro de Valorização da Vida (Brazylia, całodobowo, bezpłatnie i poufnie)"
},
"samu": {
"name": "SAMU, pogotowie ratunkowe (Brazylia)"
},
"sns24": {
"name": "SNS 24, wsparcie psychologiczne (Portugalia, całodobowo, opcja 4)"
},
"sosVozAmiga": {
"name": "SOS Voz Amiga (Portugalia, codziennie od 15:30 do 0:30)"
}
},
"instrumentDescription": {
@@ -11077,7 +11091,6 @@
"who5": "Test samopoczucia, 5 pytań. Odpowiadaj, myśląc o ostatnich dwóch tygodniach; wyższe cyfry oznaczają lepsze samopoczucie.",
"sci": "Test snu, 8 pytań. Większość pytań dotyczy ostatniego miesiąca; wyższe wyniki oznaczają lepszy sen."
},
"progress": "{current} / {total}",
"next": "Dalej",
"lastResult": "Ostatni test",
"lastScore": "Ostatni wynik",
@@ -11098,51 +11111,15 @@
"4": "Prawie cały czas",
"5": "Cały czas"
},
"sciOptions": {
"duration": {
"0": "≥ 61 min",
"1": "46–60 min",
"2": "31–45 min",
"3": "16–30 min",
"4": "0–15 min"
},
"nights": {
"0": "5–7",
"1": "4",
"2": "3",
"3": "2",
"4": "0–1"
},
"quality": {
"0": "Very poor",
"1": "Poor",
"2": "Average",
"3": "Good",
"4": "Very good"
},
"impact": {
"0": "Very much",
"1": "Much",
"2": "Somewhat",
"3": "A little",
"4": "Not at all"
},
"problemDuration": {
"0": "> 1 year",
"1": "7–12 months",
"2": "3–6 months",
"3": "1–2 months",
"4": "I don't have a problem / < 1 month"
}
},
"stems": {
"phq9": {
"period": "Jak często w ciągu ostatnich 2 tygodni dokuczały Panu/Pani następujące problemy?"
},
"gad7": {
"period": "Jak często w ciągu ostatnich 2 tygodni dokuczały Panu(i) następujące problemy?"
},
"who5": {
"period": "W ciągu ostatnich dwóch tygodni"
},
"sci": {
"night": "Thinking about a typical night in the last month …",
"impact": "Thinking about the past month, to what extent has poor sleep …",
"finally": "Finally …"
}
},
"openDetail": "Pokaż szczegóły",
@@ -11157,6 +11134,43 @@
"worsened": "To osiąga zmianę o {threshold} pkt, którą badania uznają za istotną, w mniej korzystnym kierunku.",
"within_threshold": "To mniej niż zmiana o {threshold} pkt, którą badania uznają za istotną."
}
},
"wizard": {
"progressLabel": "Pytanie {current} z {total}",
"followUpLabel": "Pytanie dodatkowe (nieobowiązkowe)",
"keyboardHint": "Klawisze od 1 do {count} wybierają odpowiedź.",
"allAnswered": "Odpowiedziano na wszystkie pytania ({total})"
},
"review": {
"title": "Sprawdź odpowiedzi",
"description": "Nic nie zostanie zapisane przed wysłaniem, a każdą odpowiedź można jeszcze zmienić.",
"change": "Zmień",
"changeLabel": "Zmień odpowiedź na pytanie {number}",
"notAnswered": "Bez odpowiedzi",
"open": "Sprawdź odpowiedzi"
},
"bandMeaning": {
"PHQ9": {
"minimal": "Twoje odpowiedzi wskazują na brak lub niewiele objawów depresji w ostatnich dwóch tygodniach.",
"mild": "Twoje odpowiedzi wskazują na łagodne objawy depresji w ostatnich dwóch tygodniach.",
"moderate": "Twoje odpowiedzi wskazują na umiarkowane objawy depresji w ostatnich dwóch tygodniach.",
"modSevere": "Twoje odpowiedzi wskazują na umiarkowanie ciężkie objawy depresji w ostatnich dwóch tygodniach.",
"severe": "Twoje odpowiedzi wskazują na ciężkie objawy depresji w ostatnich dwóch tygodniach."
},
"GAD7": {
"minimal": "Twoje odpowiedzi wskazują na brak lub niewiele objawów lęku w ostatnich dwóch tygodniach.",
"mild": "Twoje odpowiedzi wskazują na łagodne objawy lęku w ostatnich dwóch tygodniach.",
"moderate": "Twoje odpowiedzi wskazują na umiarkowane objawy lęku w ostatnich dwóch tygodniach.",
"severe": "Twoje odpowiedzi wskazują na ciężkie objawy lęku w ostatnich dwóch tygodniach."
},
"WHO5": {
"low": "Twoje samopoczucie w ostatnich dwóch tygodniach było poniżej zakresu, który WHO-5 uznaje za dobry.",
"good": "Twoje samopoczucie w ostatnich dwóch tygodniach mieściło się w zakresie, który WHO-5 uznaje za dobry."
},
"SCI": {
"belowThreshold": "Twój sen w ostatnim miesiącu mieścił się w zakresie, w którym badanie stojące za tym kwestionariuszem uznało bezsenność za prawdopodobną.",
"aboveThreshold": "Twój sen w ostatnim miesiącu był poza zakresem, który to badanie wiąże z bezsennością."
}
}
},
"records": {
@@ -11894,7 +11908,6 @@
},
"chat": {
"title": "Porozmawiaj o tym dokumencie",
"openAria": "Zapytaj Coacha o ten dokument",
"openButton": "Zapytaj Coacha"
},
"readingConsent": {
+13762
View File
File diff suppressed because it is too large. Load diff
+24 -21
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.44.2",
"version": "1.45.0",
"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",
@@ -53,8 +53,8 @@
"mcp:stdio": "npx tsx src/cli/mcp-stdio.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1150.0",
"@aws-sdk/lib-storage": "3.1150.0",
"@aws-sdk/client-s3": "^3.1152.0",
"@aws-sdk/lib-storage": "3.1152.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -62,42 +62,42 @@
"@napi-rs/canvas": "1.0.11",
"@node-rs/argon2": "^2.2.2",
"@parse/node-apn": "^8.1.0",
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
"@prisma/adapter-pg": "^7.10.1",
"@prisma/client": "^7.10.1",
"@simplewebauthn/browser": "^14.0.0",
"@simplewebauthn/server": "^14.0.3",
"@tanstack/react-query": "5.104.2",
"@tanstack/react-virtual": "^3.14.17",
"@tanstack/react-query": "5.104.4",
"@tanstack/react-virtual": "^3.14.21",
"@types/sax": "^1.2.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"dotenv": "^18.0.7",
"dotenv": "^18.0.8",
"fflate": "^0.8.3",
"jose": "^6.2.12",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8",
"lucide-react": "^1.56.0",
"jspdf-autotable": "^5.0.9",
"lucide-react": "^1.59.0",
"mmdb-lib": "^3.0.3",
"next": "16.4.2",
"nodemailer": "10.1.7",
"otpauth": "^9.5.2",
"p-limit": "^7.3.3",
"pg": "^8.23.1",
"pg-boss": "^12.37.1",
"prisma": "^7.10.0",
"pg-boss": "^12.38.0",
"prisma": "^7.10.1",
"qrcode": "^1.5.4",
"radix-ui": "^1.8.1",
"react": "19.3.0",
"react": "19.3.1",
"react-day-picker": "^10.0.2",
"react-dom": "19.3.0",
"react-dom": "19.3.1",
"recharts": "^3.10.1",
"rrule": "^2.8.1",
"sax": "^1.6.1",
"sonner": "^2.0.8",
"tailwind-merge": "^3.7.0",
"tesseract.js": "^7.0.0",
"undici": "^8.11.2",
"undici": "^8.12.2",
"web-push": "^3.6.7",
"yaml": "^2.9.1",
"zod": "^4.6.7",
@@ -105,9 +105,11 @@
"zxcvbn-typescript": "^5.0.1"
},
"devDependencies": {
"@axe-core/playwright": "^4.13.0",
"@axe-core/playwright": "^4.14.0",
"@next/bundle-analyzer": "16.4.2",
"@playwright/test": "^1.64.0",
"@playwright/test": "^1.64.1",
"@stryker-mutator/core": "10.0.0",
"@stryker-mutator/vitest-runner": "10.0.0",
"@tailwindcss/postcss": "^4",
"@tesseract.js-data/deu": "1.0.0",
"@tesseract.js-data/eng": "1.0.0",
@@ -118,21 +120,22 @@
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@types/web-push": "^3.6.4",
"@vitest/coverage-v8": "^5.0.4",
"@vitest/coverage-v8": "^5.0.5",
"ajv": "^8.20.0",
"eslint": "^9",
"eslint-config-next": "16.4.2",
"knip": "^6.41.0",
"fast-check": "4.10.2",
"knip": "^6.42.0",
"lefthook": "^2.2.2",
"pdf-parse": "^2.4.5",
"pdfjs-dist": "6.4.299",
"prettier": "^3.9.10",
"prettier-plugin-tailwindcss": "^0.8.1",
"shadcn": "^4.21.4",
"shadcn": "^4.22.1",
"tailwindcss": "^4",
"testcontainers": "^12.2.1",
"tw-animate-css": "^1.4.0",
"typescript": "^6",
"vitest": "^5.0.4"
"vitest": "^5.0.5"
}
}
@@ -0,0 +1,48 @@
diff --git a/dist/src/stryker-setup.js b/dist/src/stryker-setup.js
index aecfacbe47b285c272c47e54b3d66a716adfdc41..a6864060e466a4dde49f6d32d9fa8bc8e416baaa 100644
--- a/dist/src/stryker-setup.js
+++ b/dist/src/stryker-setup.js
@@ -57,13 +57,17 @@ else {
}
// Stryker disable all: this file is copied to the sandbox dir
function collectTestName({ name, suite, }) {
+ // Vitest 5 joins a test's full name with " > " and matches
+ // testNamePattern against that; a space-joined name matched nothing under
+ // a describe block, so every such test was skipped in mutant runs. The
+ // file task ends the chain and is not part of the name.
const nameParts = [name];
let currentSuite = suite;
- while (currentSuite) {
+ while (currentSuite && currentSuite.file !== currentSuite) {
nameParts.unshift(currentSuite.name);
currentSuite = currentSuite.suite;
}
- return nameParts.join(' ').trim();
+ return nameParts.filter(Boolean).join(' > ').trim();
}
function toRawTestId(test) {
return `${test.file?.filepath ?? 'unknown.js'}#${collectTestName(test)}`;
diff --git a/dist/src/test-helpers.js b/dist/src/test-helpers.js
index 56cc786f30c524ae18ce87eef1a67fdf26b703c5..1ad0552d5764d6b8c1ff2215e2e6fb49d7f6bc83 100644
--- a/dist/src/test-helpers.js
+++ b/dist/src/test-helpers.js
@@ -2,13 +2,17 @@
// This file is used from the testing environment (via stryker-setup.js) and thus could be loaded into the browser (when using vitest with browser mode).
// Thus we should avoid unnecessary dependencies in this file.
export function collectTestName({ name, suite, }) {
+ // Vitest 5 joins a test's full name with " > " and matches
+ // testNamePattern against that; a space-joined name matched nothing under
+ // a describe block, so every such test was skipped in mutant runs. The
+ // file task ends the chain and is not part of the name.
const nameParts = [name];
let currentSuite = suite;
- while (currentSuite) {
+ while (currentSuite && currentSuite.file !== currentSuite) {
nameParts.unshift(currentSuite.name);
currentSuite = currentSuite.suite;
}
- return nameParts.join(' ').trim();
+ return nameParts.filter(Boolean).join(' > ').trim();
}
export function toRawTestId(test) {
return `${test.file?.filepath ?? 'unknown.js'}#${collectTestName(test)}`;
+2031 -1057
View File
File diff suppressed because it is too large. Load diff
+15 -2
View File
@@ -12,8 +12,11 @@ overrides:
# GHSA-55q2-fjhq-7xh7: a crafted document gets past the sanitiser and its
# markup survives into the output. The copy here is jspdf's optional HTML
# renderer, which the doctor-report PDF path reaches.
# A later advisory against 3.4.13 to 3.4.15 raised the floor to 3.4.16.
"dompurify@<3.4.16": "^3.4.16"
# A later advisory against 3.4.13 to 3.4.15 raised the floor to 3.4.16, and
# GHSA-cg2g-xh64-v4q7 (a SAFE_FOR_TEMPLATES bypass) with GHSA-mrv2-3pqr-j5jr
# (an IN_PLACE hook that removes a parent) to 3.4.17. jspdf 4.2.1, the
# newest release, still allows the flagged range.
"dompurify@<3.4.17": "^3.4.17"
# CVE-2026-69153. Next declares an exact `postcss@8.4.31`, so nothing except
# this bound moves it. The build has run on the 8.5 line since the first
# bump; the fix landed in 8.5.23.
@@ -103,6 +106,13 @@ overrides:
"brace-expansion@>=4.0.0 <5.0.12": "^5.0.12"
"undici@>=7.0.0 <7.29.1": "^7.29.1"
# GHSA-587h-7wmj-h5pm (HIGH), GHSA-rq7h-c2jc-7f22, GHSA-r825-w8mm-5pxw,
# GHSA-698v-4r3x-3vcv and GHSA-9jrq-w75r-8gcw: dev-server file serving,
# cross-origin middleware and the bundled launch-editor. Vite arrives only
# under vitest, never in the image; vitest asks for ^8, so a floor on the
# same major moves it and nothing else.
"vite@>=8.0.0 <8.1.6": "^8.1.6"
allowBuilds:
"@prisma/engines": true
prisma: true
@@ -131,3 +141,6 @@ auditConfig:
# never runs. No patched release is published yet (latest is 1.4.0);
# remove this entry and raise the floor once 1.4.1 is out.
- GHSA-86w9-cpqp-85rv
patchedDependencies:
"@stryker-mutator/vitest-runner@10.0.0": patches/@stryker-mutator__vitest-runner@10.0.0.patch
@@ -0,0 +1,12 @@
-- #1259: a batch that carries a type with duplicates only no longer counts as
-- an arrival of that type. It stamps this column instead, so the card can
-- still say the phone re-sent the type while `last_received_at` keeps the
-- last batch that brought something new.
--
-- Nullable, no default, no backfill: null reads as "no duplicates-only batch
-- recorded", which is true for every row until the next one.
--
-- Reversibility: dropping the column loses only that stamp; nothing else
-- reads it.
ALTER TABLE "healthkit_type_syncs" ADD COLUMN IF NOT EXISTS "last_seen_duplicate_at" TIMESTAMP(3);
+14 -7
View File
@@ -4136,7 +4136,7 @@ model ManagedProfileHandover {
/// batch route; read by the Apple Health card's per-type freshness list.
/// Diagnostic state: rebuilt by the next sync, so it is not backed up.
model HealthKitTypeSync {
userId String @map("user_id")
userId String @map("user_id")
/// A `MeasurementType` name for rows the measurement batch writes, or
/// `MEDICATION_DOSE` for Apple Health doses the intake bulk route
/// receives. Text, not the enum, since v1.42.4 (migration 0401) because a
@@ -4145,20 +4145,27 @@ model HealthKitTypeSync {
/// last carried the type (its `lastReceivedAt`, every other column NULL),
/// so a catch-up leaves the live row of the type untouched; live readers
/// skip the prefix (`healthkit-type-sync.ts`).
type String
/// Last batch that carried this type.
lastReceivedAt DateTime @map("last_received_at")
type String
/// Last batch that brought a new or changed row of this type. Since #1259
/// a batch of duplicates only does not move it (nor the trigger or the
/// background stamp); it moves `lastSeenDuplicateAt` instead.
lastReceivedAt DateTime @map("last_received_at")
/// `syncTrigger` of that batch
/// (`foreground` / `background` / `push` / `manual`), NULL when absent.
lastTrigger String? @map("last_trigger")
lastTrigger String? @map("last_trigger")
/// Last batch that inserted or updated a sample of
/// this type (as opposed to sending only duplicates).
lastNewSampleAt DateTime? @map("last_new_sample_at")
lastNewSampleAt DateTime? @map("last_new_sample_at")
/// Last batch with `syncTrigger = background` that carried this type. A
/// later foreground, push or manual arrival leaves it standing; NULL when
/// none has (or only before v1.42.4, except a row whose latest arrival was
/// a background one, which migration 0401 seeded).
lastBackgroundAt DateTime? @map("last_background_at")
lastBackgroundAt DateTime? @map("last_background_at")
/// #1259 — last live batch that carried this type with duplicates only:
/// nothing new or changed, so not an arrival. NULL when none has since
/// migration 0471. Read by the Apple Health card, served on
/// `GET /api/integrations/healthkit` as `lastSeenDuplicateAt`.
lastSeenDuplicateAt DateTime? @map("last_seen_duplicate_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "en",
"locales": ["en", "de", "fr", "es", "it", "pl", "ko", "nl", "pt-BR"],
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4.5.5/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"
}
}
+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.44.2";
/* @sw-version-fallback */ "v1.45.0";
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
@@ -28,6 +28,7 @@ typecheck.
- **`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.
- **`bench/`** — `seed-large-account.ts` (a synthetic ten-year account of about 1.26 million measurement rows) and `run.ts` (latency and SQL statements per request against it); see `docs/contributing/benchmarks.md`. Results land in `bench/results/`.
- **`test-notifications.ts`** — exercise the notification dispatcher + reminder check.
- **`fetch-geolite2.sh`** — download the MaxMind GeoLite2 MMDBs (needs a licence key) before `docker build`.
- **`install-hooks.sh`** — install the local git hooks.
+3
View File
@@ -157,6 +157,9 @@ describe("container dependency installation", () => {
expect(dockerfile).toContain(
"COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./\n" +
"# The dependency patches pnpm-workspace.yaml names; the frozen install\n" +
"# refuses to run without them.\n" +
"COPY patches patches\n" +
"COPY scripts/prepare.mjs scripts/prepare.mjs\n" +
"RUN pnpm install --frozen-lockfile --prod=false",
);
+10 -1
View File
@@ -236,6 +236,7 @@ describe("release container inputs", () => {
const dockerfile = readRepoFile("Dockerfile");
const packageJson = JSON.parse(readRepoFile("package.json")) as {
packageManager: string;
dependencies: Record<string, string>;
};
expect(packageJson.packageManager).toBe("pnpm@11.15.1");
expect(dockerfile).not.toContain("pnpm@latest");
@@ -249,7 +250,15 @@ describe("release container inputs", () => {
expect(dockerfile).toContain(
"ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx",
);
expect(dockerfile).toContain("dotenv@18.0.6");
// The image's npm-installed Prisma CLI, its engines and dotenv are a second
// install of packages the app tree already declares; they move together or
// the image runs a CLI the tests never did. Drift is how the dotenv pin
// here trailed the app's by a patch.
const floor = (name: string) =>
packageJson.dependencies[name]!.replace(/^[\^~]/, "");
expect(dockerfile).toContain(`prisma@${floor("prisma")} `);
expect(dockerfile).toContain(`@prisma/engines@${floor("prisma")} `);
expect(dockerfile).toContain(`dotenv@${floor("dotenv")} `);
expect(dockerfile).toContain(
"ln -sfn /opt/prisma-cli/node_modules/dotenv /app/node_modules/dotenv",
);
+407
View File
@@ -0,0 +1,407 @@
/**
* Boot and idle-memory profile of the production build.
*
* DATABASE_URL=postgresql://… API_TOKEN_HMAC_KEY=… ENCRYPTION_KEYS=… \
* ENCRYPTION_ACTIVE_KEY_ID=… pnpm dlx tsx scripts/bench/boot-profile.ts \
* [--idle-seconds 300] [--heap-mb 792] [--port 3101] \
* [--no-snapshot] [--no-cpu-prof] [--out scripts/bench/results/<label>-boot]
*
* Starts `next start` with every role (web and worker, as production runs it)
* under the production heap ceiling, against the seeded benchmark database
* (see docs/contributing/benchmarks.md; never a database with real data: a
* heap snapshot holds whatever the process decrypted). It records:
*
* - the time to the first healthy `/api/health` and to `[boot] background
* role ready`;
* - every `event_loop_stall` the process logs, with the task it names;
* - every `runtime.heap.sampled` line;
* - with `--cpu-prof` (default on), the busy stretches of the main thread
* over 100 ms during the boot, each with its self time by source module;
* - with a snapshot (default on), one heap snapshot after the idle wait
* (taken with `--heapsnapshot-signal`, so after a full GC: the live heap),
* summarised by node type, constructor, largest strings and the largest
* retained subtrees with a retaining path.
*
* The raw `.cpuprofile` and `.heapsnapshot` are written to a temporary
* directory and deleted after the summary unless `--keep` is given.
*
* The worker's boot passes fold the seeded account while it idles; reseed
* before running `run.ts` again.
*
* Output: `<out>.json` and `<out>.md`.
*/
import { execFileSync, spawn } from "node:child_process";
import {
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { cpus, loadavg, tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { longTasks, selfTimeByModule, summariseHeap } from "./profile-analyze";
function arg(name: string, fallback: string): string {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1]! : fallback;
}
const flag = (name: string) => process.argv.includes(`--${name}`);
const IDLE_SECONDS = Number(arg("idle-seconds", "300"));
const HEAP_MB = Number(arg("heap-mb", "792"));
const PORT = Number(arg("port", "3101"));
const OUT = arg("out", join("scripts", "bench", "results", "latest-boot"));
const SNAPSHOT = !flag("no-snapshot");
const CPU_PROF = !flag("no-cpu-prof");
const KEEP = flag("keep");
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
function find(obj: unknown, key: string): unknown {
if (!obj || typeof obj !== "object") return undefined;
const rec = obj as Record<string, unknown>;
if (key in rec) return rec[key];
for (const v of Object.values(rec)) {
const hit = find(v, key);
if (hit !== undefined) return hit;
}
return undefined;
}
async function main(): Promise<void> {
if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required");
const work = mkdtempSync(join(tmpdir(), "hl-boot-profile-"));
const nodeOptions = [
`--max-old-space-size=${HEAP_MB}`,
...(CPU_PROF ? ["--cpu-prof", `--cpu-prof-dir=${work}`] : []),
...(SNAPSHOT ? ["--heapsnapshot-signal=SIGUSR2"] : []),
].join(" ");
const started = Date.now();
const child = spawn(
process.execPath,
[
// One line per collection; the mark-compacts say how much of the used
// heap was live and how much was garbage waiting for a full GC. Not
// allowed in NODE_OPTIONS, and `next start` serves in this process.
"--trace-gc",
"node_modules/next/dist/bin/next",
"start",
"-p",
String(PORT),
"-H",
"127.0.0.1",
],
{
cwd: process.cwd(),
env: {
...process.env,
NODE_ENV: "production",
PORT: String(PORT),
HOSTNAME: "127.0.0.1",
HEALTHLOG_PROCESS_TYPE: "all",
SESSION_COOKIE_SECURE: "false",
NODE_OPTIONS: nodeOptions,
},
stdio: ["ignore", "pipe", "pipe"],
},
);
// Node writes the snapshot into the process's cwd.
const snapshotDir = process.cwd();
const stalls: Array<Record<string, unknown>> = [];
const heapLines: Array<Record<string, unknown>> = [];
const bootLines: string[] = [];
const markCompacts: Array<{
atMs: number;
beforeMb: number;
afterMb: number;
}> = [];
let readyAtMs: number | null = null;
let snapshotTaken = false;
let buffer = "";
const onLine = (line: string) => {
const gc =
/Mark-Compact(?: \(reduce\))? ([\d.]+) \([\d.]+\) -> ([\d.]+) \(/.exec(
line,
);
if (gc) {
markCompacts.push({
atMs: Date.now() - started,
beforeMb: Number(gc[1]),
afterMb: Number(gc[2]),
});
return;
}
if (line.includes("[boot]")) {
bootLines.push(`${Date.now() - started} ms: ${line.slice(0, 200)}`);
if (line.includes("background role ready"))
readyAtMs = Date.now() - started;
}
if (!line.startsWith("{")) return;
try {
const evt = JSON.parse(line) as Record<string, unknown>;
const task = find(evt, "task_name");
if (task === "startup" && readyAtMs === null) {
readyAtMs = Date.now() - started;
}
if (task === "event_loop_stall") {
// The snapshot itself blocks the process for seconds.
if (snapshotTaken) return;
stalls.push({
atMs: Date.now() - started,
...(find(evt, "result") as object),
});
} else if (task === "runtime.heap.sampled") {
heapLines.push({
atMs: Date.now() - started,
...((find(evt, "meta") as object) ?? {}),
});
} else if (typeof task === "string" && task.startsWith("boot.")) {
bootLines.push(
`${Date.now() - started} ms: ${task} ${JSON.stringify(find(evt, "meta") ?? {})}`.slice(
0,
400,
),
);
}
} catch {
// not a wide event
}
};
child.stdout!.on("data", (d: Buffer) => {
buffer += d.toString();
let nl: number;
while ((nl = buffer.indexOf("\n")) >= 0) {
onLine(buffer.slice(0, nl));
buffer = buffer.slice(nl + 1);
}
});
child.stderr!.on("data", (d: Buffer) => {
for (const line of d.toString().split("\n"))
if (line.includes("[boot]")) onLine(line);
});
let healthyAtMs: number | null = null;
const deadline = Date.now() + 180_000;
while (Date.now() < deadline && healthyAtMs === null) {
if (child.exitCode !== null)
throw new Error(`server exited ${child.exitCode}`);
try {
const res = await fetch(`http://127.0.0.1:${PORT}/api/health`);
if (res.status < 500 || res.status === 503) {
const body = await res.text();
if (res.ok || body.includes("starting"))
healthyAtMs = Date.now() - started;
}
} catch {
// not listening yet
}
await sleep(100);
}
while (readyAtMs === null && Date.now() < deadline) await sleep(200);
console.log(
`health ${healthyAtMs} ms, worker ready ${readyAtMs} ms; idling ${IDLE_SECONDS} s`,
);
await sleep(IDLE_SECONDS * 1000);
let heap: ReturnType<typeof summariseHeap> | null = null;
if (SNAPSHOT) {
const before = new Set(readdirSync(snapshotDir));
snapshotTaken = true;
child.kill("SIGUSR2");
let file = "";
let lastSize = -1;
for (let i = 0; i < 600; i++) {
await sleep(1000);
const f = readdirSync(snapshotDir).find(
(n) => n.endsWith(".heapsnapshot") && !before.has(n),
);
if (!f) continue;
const size = statSync(join(snapshotDir, f)).size;
if (size === lastSize && size > 0) {
file = join(snapshotDir, f);
break;
}
lastSize = size;
}
if (file) {
// A snapshot is finished when the process answers again.
await fetch(`http://127.0.0.1:${PORT}/api/health`).catch(() => undefined);
const moved = join(work, "idle.heapsnapshot");
renameSync(file, moved);
console.log("summarising heap snapshot");
heap = summariseHeap(moved, {
"parsed locale catalogs": ["common", "apiErrors", "doctorReport"],
});
}
}
const exited = new Promise((r) => child.once("exit", r));
child.kill("SIGTERM");
await Promise.race([exited, sleep(60_000)]);
if (child.exitCode === null) child.kill("SIGKILL");
let tasks: ReturnType<typeof longTasks> = [];
let bootSelf: Array<[string, number]> = [];
if (CPU_PROF) {
await sleep(1000);
const profiles = readdirSync(work).filter((n) => n.endsWith(".cpuprofile"));
// The largest profile is the server process (`next start` may run a
// small parent).
const main = profiles
.map((n) => join(work, n))
.sort((a, b) => statSync(b).size - statSync(a).size)[0];
if (main) {
const profile = JSON.parse(readFileSync(main, "utf8"));
tasks = longTasks(profile, 50, 0.5).filter((t) => t.startMs < 120_000);
bootSelf = selfTimeByModule(
profile,
0,
(readyAtMs ?? 30_000) + 5_000,
).slice(0, 40);
}
}
if (!KEEP) rmSync(work, { recursive: true, force: true });
else console.log(`raw files kept in ${work}`);
let commit = "unknown";
try {
commit = execFileSync("git", ["rev-parse", "--short", "HEAD"])
.toString()
.trim();
} catch {
// not a checkout
}
const meta = {
label: OUT.split("/").pop(),
commit,
date: new Date().toISOString(),
node: process.version,
cpus: cpus().length,
loadAverage: loadavg().map((n) => Math.round(n * 10) / 10),
heapMb: HEAP_MB,
idleSeconds: IDLE_SECONDS,
healthyAtMs,
readyAtMs,
stallMaxMs: Math.max(0, ...stalls.map((s) => Number(s.lag_ms ?? 0))),
};
mkdirSync(dirname(OUT), { recursive: true });
// Paths in frame and retainer names are made relative to the checkout,
// before anything shortens them.
const relative = (text: string) => text.split(`${process.cwd()}/`).join("");
if (heap) heap = JSON.parse(relative(JSON.stringify(heap))) as typeof heap;
tasks = JSON.parse(relative(JSON.stringify(tasks))) as typeof tasks;
writeFileSync(
`${OUT}.json`,
relative(
`${JSON.stringify({ meta, bootLines, stalls, heapLines, markCompacts, tasks, bootSelf, heap }, null, 2)}\n`,
),
);
const md: string[] = [
`# Boot profile ${String(meta.label)}`,
"",
`Commit ${commit}, ${meta.date}. Production build, every role, heap ceiling ${HEAP_MB} MB, ${IDLE_SECONDS} s idle. ${meta.cpus} cores, load average ${meta.loadAverage.join(",")} at the end.`,
"",
`- first answer from /api/health: ${healthyAtMs} ms; background role ready: ${readyAtMs} ms`,
`- event-loop stalls logged: ${stalls.length}, max ${meta.stallMaxMs} ms`,
"",
"## Stalls",
"",
...stalls.map((s) => `- ${JSON.stringify(s)}`),
"",
"## Heap samples",
"",
"| at s | used MB | old | large obj | code | external | array buffers | rss |",
"| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
...heapLines.map(
(h) =>
`| ${Math.round(Number(h.atMs) / 1000)} | ${h.heap_used_mb} | ${h.old_space_mb} | ${h.large_object_space_mb} | ${h.code_space_mb} | ${h.external_mb} | ${h.array_buffers_mb} | ${h.rss_mb} |`,
),
"",
"## Full collections (mark-compact)",
"",
`${markCompacts.length} mark-compacts during the run. Used heap before the collection, and what survived it (the live heap):`,
"",
"| at s | before MB | after MB |",
"| ---: | ---: | ---: |",
...markCompacts
.filter(
(m, i, all) =>
i === all.length - 1 || i % Math.ceil(all.length / 15) === 0,
)
.map(
(m) =>
`| ${Math.round(m.atMs / 1000)} | ${m.beforeMb} | ${m.afterMb} |`,
),
"",
"## Main-thread busy stretches over 50 ms without an idle sample, first two minutes",
"",
...tasks.flatMap((t) => [
`### at ${t.startMs} ms, ${t.durationMs} ms`,
"",
"| self time by module | ms |",
"| --- | ---: |",
...t.byModule.slice(0, 10).map(([m, v]) => `| ${m} | ${v} |`),
"",
"| inclusive, application modules | ms |",
"| --- | ---: |",
...t.byEntry.slice(0, 10).map(([m, v]) => `| ${m} | ${v} |`),
"",
]),
"## Self time by module until the worker is ready",
"",
"| module | ms |",
"| --- | ---: |",
...bootSelf.slice(0, 25).map(([m, v]) => `| ${m} | ${v} |`),
"",
];
if (heap) {
md.push(
`## Heap snapshot after ${IDLE_SECONDS} s idle (live heap after a full GC)`,
"",
`${heap.totalMb} MB in ${heap.nodeCount} nodes; module source strings over 50 KB: ${heap.sourceStringsMb} MB.`,
"",
...Object.entries(heap.probes).map(
([k, v]) => `- ${k}: ${v.count} objects, ${v.retainedMb} MB retained`,
),
"",
"| node type | MB |",
"| --- | ---: |",
...heap.byType.map(([k, v]) => `| ${k} | ${v} |`),
"",
"| constructor | MB | count |",
"| --- | ---: | ---: |",
...heap.byConstructor
.slice(0, 20)
.map(([k, v, c]) => `| ${k.replace(/\|/g, "/")} | ${v} | ${c} |`),
"",
"| largest retained subtrees | MB | retaining path |",
"| --- | ---: | --- |",
...heap.topRetainers
.slice(0, 30)
.map(
(r) =>
`| ${r.name.replace(/\|/g, "/")} | ${r.mb} | ${r.path.replace(/\|/g, "/").slice(0, 160)} |`,
),
"",
"| largest strings | MB |",
"| --- | ---: |",
...heap.largestStrings
.slice(0, 15)
.map(([s, t]) => `| ${t.replace(/\|/g, "/").slice(0, 90)} | ${s} |`),
"",
);
}
writeFileSync(`${OUT}.md`, relative(md.join("\n")));
console.log(`wrote ${OUT}.json and ${OUT}.md`);
}
main().catch((err) => {
console.error(err);
process.exitCode = 1;
});
+599
View File
@@ -0,0 +1,599 @@
/**
* Readers for the two files `boot-profile.ts` collects: a V8 CPU profile
* (`.cpuprofile`) and a heap snapshot (`.heapsnapshot`). Both are plain
* JSON; these turn them into the few tables a boot investigation needs, so a
* before/after comparison does not depend on opening DevTools by hand.
*
* Frames are attributed to source modules through the module ids Turbopack
* writes into every server chunk (`"[project]/src/lib/x.ts [app-route] …"`):
* a sample at chunk line N belongs to the last module id that starts at or
* before N.
*/
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
// ---------------------------------------------------------------- modules
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const B64_INDEX = new Map([...B64].map((c, i) => [c, i]));
interface ChunkMap {
sources: string[];
/** Per generated line: [column, source index] pairs, ascending. */
lines: Array<Array<[number, number]>>;
}
const chunkMapCache = new Map<string, ChunkMap | null>();
/** Decode a chunk's source map down to (line, column) → source. */
function chunkMap(url: string): ChunkMap | null {
if (chunkMapCache.has(url)) return chunkMapCache.get(url)!;
let path = url;
try {
if (url.startsWith("file://")) path = fileURLToPath(url);
} catch {
// not a file URL
}
let result: ChunkMap | null = null;
if (existsSync(`${path}.map`)) {
type RawMap = {
sources?: string[];
mappings?: string;
sections?: Array<{ offset: { line: number }; map: RawMap }>;
};
const raw = JSON.parse(readFileSync(`${path}.map`, "utf8")) as RawMap;
const sources: string[] = [];
const lines: Array<Array<[number, number]>> = [];
// An index map (`sections`) holds one ordinary map per offset.
const flat: Array<{ line: number; map: RawMap }> = raw.sections
? raw.sections.map((s) => ({ line: s.offset.line, map: s.map }))
: [{ line: 0, map: raw }];
for (const { line: lineOffset, map } of flat) {
const base = sources.length;
sources.push(...(map.sources ?? []));
let src = 0;
let lineNo = lineOffset;
for (const line of (map.mappings ?? "").split(";")) {
const segs: Array<[number, number]> = lines[lineNo] ?? [];
let col = 0;
for (const seg of line.split(",")) {
if (!seg) continue;
const vals: number[] = [];
let shift = 0;
let value = 0;
for (const ch of seg) {
const d = B64_INDEX.get(ch)!;
value += (d & 31) << shift;
if (d & 32) shift += 5;
else {
const neg = value & 1;
value >>= 1;
vals.push(neg ? -value : value);
value = 0;
shift = 0;
}
}
col += vals[0] ?? 0;
if (vals.length > 1) {
src += vals[1]!;
segs.push([col, base + src]);
}
}
lines[lineNo] = segs;
lineNo++;
}
}
for (const segs of lines) segs?.sort((x, y) => x[0] - y[0]);
result = { sources, lines };
}
chunkMapCache.set(url, result);
return result;
}
function packageOf(path: string): string {
const parts = path.split("node_modules/");
const rest = parts[parts.length - 1]!.split("/");
return `npm:${rest[0]!.startsWith("@") ? `${rest[0]}/${rest[1]}` : rest[0]}`;
}
/** The source module a frame at (url, 0-based line, column) belongs to. */
export function moduleForFrame(url: string, line: number, column = 0): string {
if (!url) return "(native)";
if (url.startsWith("node:")) return url;
if (url.includes("/node_modules/") && !url.includes("/.next/")) {
return packageOf(url);
}
const map = chunkMap(url);
const segs = map?.lines[line];
if (map && segs && segs.length > 0) {
let lo = 0;
let hi = segs.length - 1;
let found = segs[0]![1];
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (segs[mid]![0] <= column) {
found = segs[mid]![1];
lo = mid + 1;
} else hi = mid - 1;
}
const source = (map.sources[found] ?? "")
.replace(/^turbopack:\/\/\/\[project\]\//, "")
.replace(/^(\.\.\/)+/, "");
if (source.includes("node_modules/")) return packageOf(source);
return source || url;
}
return url.replace(/^.*\/\.next\//, ".next/");
}
// ------------------------------------------------------------ CPU profile
interface CpuNode {
id: number;
callFrame: {
functionName: string;
url: string;
lineNumber: number;
columnNumber: number;
};
children?: number[];
}
interface CpuProfile {
nodes: CpuNode[];
startTime: number;
endTime: number;
samples: number[];
timeDeltas: number[];
}
export interface LongTask {
startMs: number;
durationMs: number;
/** Self time by source module, descending. */
byModule: Array<[string, number]>;
/** Self time by function, descending. */
byFunction: Array<[string, number]>;
/** Inclusive time of the outermost application frames. */
byEntry: Array<[string, number]>;
}
const IDLE = new Set(["(idle)", "(root)"]);
/**
* Busy stretches of the main thread: consecutive non-idle samples whose gaps
* stay under `gapMs`, longer than `minMs`. Each comes with its self time by
* module and by function.
*/
export function longTasks(
profile: CpuProfile,
minMs = 100,
gapMs = 5,
): LongTask[] {
const byId = new Map<number, CpuNode>();
const parent = new Map<number, number>();
for (const n of profile.nodes) {
byId.set(n.id, n);
for (const c of n.children ?? []) parent.set(c, n.id);
}
const tasks: LongTask[] = [];
let current: {
start: number;
end: number;
samples: Array<[number, number]>;
} | null = null;
const flush = () => {
if (!current) return;
const dur = (current.end - current.start) / 1000;
if (dur >= minMs) {
const mods = new Map<string, number>();
const fns = new Map<string, number>();
const entries = new Map<string, number>();
for (const [id, dt] of current.samples) {
const n = byId.get(id)!;
const cf = n.callFrame;
const mod = moduleForFrame(cf.url, cf.lineNumber, cf.columnNumber);
mods.set(mod, (mods.get(mod) ?? 0) + dt);
const fn = `${cf.functionName || "(anonymous)"} ${mod}`;
fns.set(fn, (fns.get(fn) ?? 0) + dt);
// Inclusive: every distinct module on the stack gets the sample once.
const seen = new Set<string>();
let cur: number | undefined = id;
while (cur !== undefined) {
const c = byId.get(cur)!.callFrame;
if (c.url) {
const m = moduleForFrame(c.url, c.lineNumber, c.columnNumber);
if (m.startsWith("src/") && !seen.has(m)) {
seen.add(m);
entries.set(m, (entries.get(m) ?? 0) + dt);
}
}
cur = parent.get(cur);
}
}
const top = (m: Map<string, number>) =>
[...m.entries()]
.map(([k, v]) => [k, Math.round(v / 100) / 10] as [string, number])
.sort((a, b) => b[1] - a[1])
.slice(0, 15);
tasks.push({
startMs: Math.round((current.start - profile.startTime) / 1000),
durationMs: Math.round(dur),
byModule: top(mods),
byFunction: top(fns),
byEntry: top(entries),
});
}
current = null;
};
let at = profile.startTime;
for (let i = 0; i < profile.samples.length; i++) {
at += profile.timeDeltas[i] ?? 0;
const id = profile.samples[i]!;
const name = byId.get(id)?.callFrame.functionName ?? "";
const dt = profile.timeDeltas[i + 1] ?? 0;
if (IDLE.has(name)) {
if (current && at - current.end > gapMs * 1000) flush();
continue;
}
if (!current) current = { start: at, end: at, samples: [] };
else if (at - current.end > gapMs * 1000) {
flush();
current = { start: at, end: at, samples: [] };
}
current.end = at + dt;
current.samples.push([id, dt]);
}
flush();
return tasks;
}
/** Self time per module over the whole profile, or a window of it. */
export function selfTimeByModule(
profile: CpuProfile,
fromMs = 0,
toMs = Infinity,
): Array<[string, number]> {
const byId = new Map(profile.nodes.map((n) => [n.id, n]));
const mods = new Map<string, number>();
let at = profile.startTime;
for (let i = 0; i < profile.samples.length; i++) {
at += profile.timeDeltas[i] ?? 0;
const rel = (at - profile.startTime) / 1000;
if (rel < fromMs || rel > toMs) continue;
const n = byId.get(profile.samples[i]!)!;
if (IDLE.has(n.callFrame.functionName)) continue;
const mod = moduleForFrame(
n.callFrame.url,
n.callFrame.lineNumber,
n.callFrame.columnNumber,
);
const dt = profile.timeDeltas[i + 1] ?? 0;
mods.set(mod, (mods.get(mod) ?? 0) + dt);
}
return [...mods.entries()]
.map(([k, v]) => [k, Math.round(v / 100) / 10] as [string, number])
.sort((a, b) => b[1] - a[1]);
}
// ---------------------------------------------------------- heap snapshot
interface HeapSnapshot {
snapshot: {
meta: {
node_fields: string[];
node_types: [string[], ...unknown[]];
edge_fields: string[];
edge_types: [string[], ...unknown[]];
};
node_count: number;
};
nodes: number[];
edges: number[];
strings: string[];
}
export interface HeapSummary {
totalMb: number;
nodeCount: number;
byType: Array<[string, number]>;
byConstructor: Array<[string, number, number]>;
largestStrings: Array<[number, string]>;
/** Retained size of the largest dominator subtrees below the GC roots. */
topRetainers: Array<{ mb: number; name: string; path: string }>;
/** Module source strings (whole chunk scripts) over 50 KB. */
sourceStringsMb: number;
/**
* For each probe, the objects that carry every listed property: how many,
* and their summed retained size (the outermost ones only).
*/
probes: Record<string, { count: number; retainedMb: number }>;
}
/**
* Self size by node type and constructor, the largest strings, and retained
* sizes from the dominator tree (Cooper, Harvey, Kennedy), with a retaining
* path for each of the largest subtrees.
*/
export function summariseHeap(
file: string,
probes: Record<string, string[]> = {},
): HeapSummary {
const snap = JSON.parse(readFileSync(file, "utf8")) as HeapSnapshot;
const nf = snap.snapshot.meta.node_fields;
const ef = snap.snapshot.meta.edge_fields;
const nodeTypes = snap.snapshot.meta.node_types[0];
const edgeTypes = snap.snapshot.meta.edge_types[0];
const NF = nf.length;
const EF = ef.length;
const iType = nf.indexOf("type");
const iName = nf.indexOf("name");
const iSelf = nf.indexOf("self_size");
const iEdges = nf.indexOf("edge_count");
const eType = ef.indexOf("type");
const eName = ef.indexOf("name_or_index");
const eTo = ef.indexOf("to_node");
const weak = edgeTypes.indexOf("weak");
const shortcut = edgeTypes.indexOf("shortcut");
const elementT = edgeTypes.indexOf("element");
const hiddenT = edgeTypes.indexOf("hidden");
const N = snap.nodes.length / NF;
const nodes = snap.nodes;
const edges = snap.edges;
const strings = snap.strings;
const firstEdge = new Uint32Array(N + 1);
for (let i = 0, e = 0; i < N; i++) {
firstEdge[i] = e;
e += nodes[i * NF + iEdges]! * EF;
}
firstEdge[N] = edges.length;
const self = (i: number) => nodes[i * NF + iSelf]!;
const typeOf = (i: number) => nodeTypes[nodes[i * NF + iType]!]!;
const nameOf = (i: number) => strings[nodes[i * NF + iName]!]!;
let total = 0;
const byType = new Map<string, number>();
const byCtor = new Map<string, [number, number]>();
const big: Array<[number, string]> = [];
let sourceStrings = 0;
for (let i = 0; i < N; i++) {
const s = self(i);
total += s;
const t = typeOf(i);
byType.set(t, (byType.get(t) ?? 0) + s);
if (t === "object" || t === "closure" || t === "array" || t === "native") {
const k = `${t}:${nameOf(i)}`.slice(0, 80);
const cur = byCtor.get(k) ?? [0, 0];
cur[0] += s;
cur[1] += 1;
byCtor.set(k, cur);
}
if (
t === "string" ||
t === "concatenated string" ||
t === "sliced string"
) {
if (s > 200_000)
big.push([s, nameOf(i).slice(0, 100).replace(/\s+/g, " ")]);
const nm = nameOf(i);
if (
s > 50_000 &&
/__turbopack_context__|"use strict";|module\.exports/.test(
nm.slice(0, 2000),
)
) {
sourceStrings += s;
}
}
}
// Graph for the dominator tree: strong edges only.
const isStrong = (et: number) => et !== weak && et !== shortcut;
// Post-order DFS from node 0.
const order = new Int32Array(N).fill(-1);
const post: number[] = [];
const stackN: number[] = [0];
const stackE: number[] = [firstEdge[0]!];
order[0] = -2;
while (stackN.length) {
const top = stackN.length - 1;
const n = stackN[top]!;
const e = stackE[top]!;
if (e < firstEdge[n + 1]!) {
stackE[top] = e + EF;
const et = edges[e + eType]!;
if (!isStrong(et)) continue;
const to = edges[e + eTo]! / NF;
if (order[to] === -1) {
order[to] = -2;
stackN.push(to);
stackE.push(firstEdge[to]!);
}
} else {
order[n] = post.length;
post.push(n);
stackN.pop();
stackE.pop();
}
}
const R = post.length;
// Predecessors in post-order numbering (CSR).
const predCount = new Uint32Array(R + 1);
for (let n = 0; n < N; n++) {
if (order[n]! < 0) continue;
for (let e = firstEdge[n]!; e < firstEdge[n + 1]!; e += EF) {
if (!isStrong(edges[e + eType]!)) continue;
const to = edges[e + eTo]! / NF;
if (order[to]! >= 0) predCount[order[to]! + 1]! += 1;
}
}
for (let i = 0; i < R; i++) predCount[i + 1]! += predCount[i]!;
const preds = new Uint32Array(predCount[R]!);
const fill = predCount.slice(0, R);
for (let n = 0; n < N; n++) {
if (order[n]! < 0) continue;
for (let e = firstEdge[n]!; e < firstEdge[n + 1]!; e += EF) {
if (!isStrong(edges[e + eType]!)) continue;
const to = edges[e + eTo]! / NF;
if (order[to]! >= 0) preds[fill[order[to]!]!++] = order[n]!;
}
}
const rootPo = order[0]!;
const idom = new Int32Array(R).fill(-1);
idom[rootPo] = rootPo;
let changed = true;
while (changed) {
changed = false;
for (let b = R - 1; b >= 0; b--) {
if (b === rootPo) continue;
let ni = -1;
for (let p = predCount[b]!; p < predCount[b + 1]!; p++) {
let q = preds[p]!;
if (idom[q] === -1) continue;
if (ni === -1) {
ni = q;
continue;
}
let a = ni;
while (a !== q) {
while (a < q) a = idom[a]!;
while (q < a) q = idom[q]!;
}
ni = a;
}
if (ni !== -1 && idom[b] !== ni) {
idom[b] = ni;
changed = true;
}
}
}
const retained = new Float64Array(R);
for (let b = 0; b < R; b++) retained[b] += self(post[b]!);
for (let b = 0; b < R; b++) {
if (b !== rootPo && idom[b]! >= 0) retained[idom[b]!] += retained[b]!;
}
// A retaining path: the first strong, named predecessor edge, walked up.
const retainerEdge = new Map<number, [number, string]>();
const wanted = new Set<number>();
const candidates: number[] = [];
for (let b = 0; b < R; b++) {
const n = post[b]!;
const t = typeOf(n);
if (b === rootPo || t === "synthetic" || t === "hidden") continue;
// Skip nodes whose immediate dominator is itself a big application
// object: report the outermost holders only.
if (retained[b]! < 2_000_000) continue;
candidates.push(b);
}
candidates.sort((a, b) => retained[b]! - retained[a]!);
const picked: number[] = [];
for (const b of candidates) {
// keep the outermost: skip if an ancestor in the dominator tree is
// already picked and within 1.5× (it would be a duplicate line)
let a = idom[b]!;
let dup = false;
while (a !== rootPo && a >= 0) {
if (picked.includes(a) && retained[a]! < retained[b]! * 1.5) {
dup = true;
break;
}
a = idom[a]!;
}
if (!dup) picked.push(b);
if (picked.length >= 40) break;
}
for (const b of picked) wanted.add(post[b]!);
// Build one retainer per node in a BFS from the root (shortest path).
const dist = new Int32Array(N).fill(-1);
const queue = new Int32Array(N);
let qh = 0;
let qt = 0;
queue[qt++] = 0;
dist[0] = 0;
while (qh < qt) {
const n = queue[qh++]!;
for (let e = firstEdge[n]!; e < firstEdge[n + 1]!; e += EF) {
const et = edges[e + eType]!;
if (!isStrong(et)) continue;
const to = edges[e + eTo]! / NF;
if (dist[to] !== -1) continue;
dist[to] = dist[n]! + 1;
const label =
et === elementT || et === hiddenT
? `[${edges[e + eName]}]`
: String(strings[edges[e + eName]!] ?? "");
retainerEdge.set(to, [n, label]);
queue[qt++] = to;
}
}
const pathOf = (n: number) => {
const parts: string[] = [];
let cur = n;
for (let depth = 0; depth < 12 && cur !== 0; depth++) {
const r = retainerEdge.get(cur);
if (!r) break;
parts.unshift(`${r[1]}`);
cur = r[0];
const t = typeOf(cur);
if (t !== "hidden" && t !== "synthetic" && depth > 0) {
parts.unshift(`${nameOf(cur).slice(0, 40)}`);
if (depth > 4) break;
}
}
return parts.join(" > ");
};
const mb = (n: number) => Math.round((n / 1048576) * 10) / 10;
const probeResults: HeapSummary["probes"] = {};
const propertyT = edgeTypes.indexOf("property");
for (const [label, props] of Object.entries(probes)) {
const hits = new Set<number>();
for (let n = 0; n < N; n++) {
if (typeOf(n) !== "object" || order[n]! < 0) continue;
const seen = new Set<string>();
for (let e = firstEdge[n]!; e < firstEdge[n + 1]!; e += EF) {
if (edges[e + eType] !== propertyT) continue;
seen.add(strings[edges[e + eName]!]!);
}
if (props.every((p) => seen.has(p))) hits.add(order[n]!);
}
let retainedSum = 0;
for (const b of hits) {
// Count a hit only when no dominator above it is a hit too.
let a = idom[b]!;
let nested = false;
while (a !== rootPo && a >= 0) {
if (hits.has(a)) {
nested = true;
break;
}
a = idom[a]!;
}
if (!nested) retainedSum += retained[b]!;
}
probeResults[label] = { count: hits.size, retainedMb: mb(retainedSum) };
}
return {
probes: probeResults,
totalMb: mb(total),
nodeCount: N,
byType: [...byType.entries()]
.map(([k, v]) => [k, mb(v)] as [string, number])
.sort((a, b) => b[1] - a[1]),
byConstructor: [...byCtor.entries()]
.map(([k, [s, c]]) => [k, mb(s), c] as [string, number, number])
.sort((a, b) => b[1] - a[1])
.slice(0, 30),
largestStrings: big
.sort((a, b) => b[0] - a[0])
.slice(0, 25)
.map(([s, t]) => [mb(s), t]),
topRetainers: picked.map((b) => ({
mb: mb(retained[b]!),
name: `${typeOf(post[b]!)}:${nameOf(post[b]!).slice(0, 60).replace(/\s+/g, " ")}`,
path: pathOf(post[b]!),
})),
sourceStringsMb: mb(sourceStrings),
};
}
+254
View File
@@ -0,0 +1,254 @@
{
"meta": {
"label": "v1.44.0-baseline",
"commit": "d640eea32",
"date": "2026-10-22T03:54:05.746Z",
"node": "v22.23.2",
"postgres": "16.15",
"cpus": 14,
"loadAverage": [
9.4,
20.1,
20
],
"measurements": 1266549,
"iterations": 20,
"warmup": 3,
"heapMb": 792,
"idleStatementsPerSecond": 0,
"batch5k": {
"totalMs": 1722,
"totalStatements": 424
}
},
"results": [
{
"name": "/api/auth/me",
"mode": "cold",
"request": "GET /api/auth/me",
"n": 20,
"p50Ms": 12.1,
"p95Ms": 17.4,
"maxMs": 18.4,
"statementsMedian": 19,
"statementsMax": 19,
"failures": 0
},
{
"name": "/api/auth/me",
"mode": "warm",
"request": "GET /api/auth/me",
"n": 20,
"p50Ms": 16.6,
"p95Ms": 22.2,
"maxMs": 24.4,
"statementsMedian": 19,
"statementsMax": 19,
"failures": 0
},
{
"name": "dashboard summary",
"mode": "cold",
"request": "GET /api/dashboard/summary",
"n": 20,
"p50Ms": 205.6,
"p95Ms": 233.2,
"maxMs": 275.3,
"statementsMedian": 31,
"statementsMax": 32,
"failures": 0
},
{
"name": "dashboard summary",
"mode": "warm",
"request": "GET /api/dashboard/summary",
"n": 20,
"p50Ms": 12.8,
"p95Ms": 14.3,
"maxMs": 27.5,
"statementsMedian": 7,
"statementsMax": 7,
"failures": 0
},
{
"name": "dashboard snapshot",
"mode": "cold",
"request": "GET /api/dashboard/snapshot",
"n": 20,
"p50Ms": 2157.8,
"p95Ms": 2252.7,
"maxMs": 2292.9,
"statementsMedian": 158,
"statementsMax": 161,
"failures": 0
},
{
"name": "dashboard snapshot",
"mode": "warm",
"request": "GET /api/dashboard/snapshot",
"n": 20,
"p50Ms": 15.6,
"p95Ms": 23.5,
"maxMs": 25.6,
"statementsMedian": 13,
"statementsMax": 16,
"failures": 0
},
{
"name": "series pulse 30 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=30",
"n": 20,
"p50Ms": 50.8,
"p95Ms": 58.7,
"maxMs": 58.9,
"statementsMedian": 8,
"statementsMax": 9,
"failures": 0
},
{
"name": "series pulse 30 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=30",
"n": 20,
"p50Ms": 52.3,
"p95Ms": 59.5,
"maxMs": 59.9,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "series pulse 365 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=365",
"n": 20,
"p50Ms": 289,
"p95Ms": 311.3,
"maxMs": 363.7,
"statementsMedian": 8,
"statementsMax": 9,
"failures": 0
},
{
"name": "series pulse 365 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=365",
"n": 20,
"p50Ms": 274.3,
"p95Ms": 355.8,
"maxMs": 366.8,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "series pulse 3650 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=3650",
"n": 20,
"p50Ms": 1502.6,
"p95Ms": 2117.6,
"maxMs": 2122.9,
"statementsMedian": 8,
"statementsMax": 9,
"failures": 0
},
{
"name": "series pulse 3650 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=3650",
"n": 20,
"p50Ms": 1490.3,
"p95Ms": 1505.5,
"maxMs": 1555.2,
"statementsMedian": 8,
"statementsMax": 11,
"failures": 0
},
{
"name": "day view",
"mode": "cold",
"request": "GET /api/day/2026-10-21",
"n": 20,
"p50Ms": 61.7,
"p95Ms": 77.5,
"maxMs": 86.1,
"statementsMedian": 49,
"statementsMax": 50,
"failures": 0
},
{
"name": "day view",
"mode": "warm",
"request": "GET /api/day/2026-10-21",
"n": 20,
"p50Ms": 49,
"p95Ms": 55.6,
"maxMs": 62.5,
"statementsMedian": 48,
"statementsMax": 48,
"failures": 0
},
{
"name": "insights health status",
"mode": "cold",
"request": "GET /api/insights/health-status",
"n": 20,
"p50Ms": 124.1,
"p95Ms": 139.5,
"maxMs": 141.5,
"statementsMedian": 68,
"statementsMax": 68,
"failures": 0
},
{
"name": "insights health status",
"mode": "warm",
"request": "GET /api/insights/health-status",
"n": 20,
"p50Ms": 124.7,
"p95Ms": 157.8,
"maxMs": 165.3,
"statementsMedian": 68,
"statementsMax": 68,
"failures": 0
},
{
"name": "insights correlations",
"mode": "cold",
"request": "GET /api/insights/correlations",
"n": 20,
"p50Ms": 95.4,
"p95Ms": 105,
"maxMs": 108.8,
"statementsMedian": 45,
"statementsMax": 45,
"failures": 0
},
{
"name": "insights correlations",
"mode": "warm",
"request": "GET /api/insights/correlations",
"n": 20,
"p50Ms": 13,
"p95Ms": 15.4,
"maxMs": 15.9,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "batch ingest 5k (per 500)",
"mode": "write",
"request": "POST /api/measurements/batch ×10",
"n": 10,
"p50Ms": 163,
"p95Ms": 220.7,
"maxMs": 220.7,
"statementsMedian": 42,
"statementsMax": 45,
"failures": 0
}
]
}
+25
View File
@@ -0,0 +1,25 @@
# Benchmark v1.44.0-baseline
Commit d640eea32, 2026-10-22T03:54:05.746Z. 1266549 measurement rows on the bench account, 20 measured requests per row after 3 warm-up requests, one at a time. Server: production build, web process, heap ceiling 792 MB. Postgres 16.15, 14 cores, load average 9.4,20.1,20 at the end. Idle background: 0 statements/s.
| Request | Mode | n | p50 ms | p95 ms | max ms | statements (median) | statements (max) |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
| /api/auth/me | cold | 20 | 12.1 | 17.4 | 18.4 | 19 | 19 |
| /api/auth/me | warm | 20 | 16.6 | 22.2 | 24.4 | 19 | 19 |
| dashboard summary | cold | 20 | 205.6 | 233.2 | 275.3 | 31 | 32 |
| dashboard summary | warm | 20 | 12.8 | 14.3 | 27.5 | 7 | 7 |
| dashboard snapshot | cold | 20 | 2157.8 | 2252.7 | 2292.9 | 158 | 161 |
| dashboard snapshot | warm | 20 | 15.6 | 23.5 | 25.6 | 13 | 16 |
| series pulse 30 d | cold | 20 | 50.8 | 58.7 | 58.9 | 8 | 9 |
| series pulse 30 d | warm | 20 | 52.3 | 59.5 | 59.9 | 8 | 8 |
| series pulse 365 d | cold | 20 | 289 | 311.3 | 363.7 | 8 | 9 |
| series pulse 365 d | warm | 20 | 274.3 | 355.8 | 366.8 | 8 | 8 |
| series pulse 3650 d | cold | 20 | 1502.6 | 2117.6 | 2122.9 | 8 | 9 |
| series pulse 3650 d | warm | 20 | 1490.3 | 1505.5 | 1555.2 | 8 | 11 |
| day view | cold | 20 | 61.7 | 77.5 | 86.1 | 49 | 50 |
| day view | warm | 20 | 49 | 55.6 | 62.5 | 48 | 48 |
| insights health status | cold | 20 | 124.1 | 139.5 | 141.5 | 68 | 68 |
| insights health status | warm | 20 | 124.7 | 157.8 | 165.3 | 68 | 68 |
| insights correlations | cold | 20 | 95.4 | 105 | 108.8 | 45 | 45 |
| insights correlations | warm | 20 | 13 | 15.4 | 15.9 | 8 | 8 |
| batch ingest 5k (per 500) | write | 10 | 163 | 220.7 | 220.7 | 42 | 45 |
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,221 @@
# Boot profile v1.45.0-before-boot
Commit 0f6d668df, 2026-10-22T04:36:55.826Z. Production build, every role, heap ceiling 792 MB, 300 s idle. 14 cores, load average 9.3,9.4,11.2 at the end.
- first answer from /api/health: 1590 ms; background role ready: 5432 ms
- event-loop stalls logged: 0, max 0 ms
## Stalls
## Heap samples
| at s | used MB | old | large obj | code | external | array buffers | rss |
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| 6 | 184 | 105 | 68 | 4 | 11 | 0 | 267 |
| 308 | 164 | 99 | 58 | 5 | 11 | 0 | 623 |
## Full collections (mark-compact)
47 mark-compacts during the run. Used heap before the collection, and what survived it (the live heap):
| at s | before MB | after MB |
| ---: | ---: | ---: |
| 0 | 21.4 | 17.4 |
| 9 | 189.6 | 172.9 |
| 154 | 193.8 | 171.6 |
| 170 | 187.3 | 161.7 |
| 188 | 181.6 | 163.6 |
| 205 | 182.3 | 163.6 |
| 222 | 190.6 | 165 |
| 239 | 182.9 | 163.4 |
| 258 | 190.5 | 163.7 |
| 274 | 187.7 | 164.2 |
| 290 | 193.9 | 164.2 |
| 306 | 175.3 | 163.9 |
| 309 | 174 | 163.7 |
## Main-thread busy stretches over 50 ms without an idle sample, first two minutes
### at 5 ms, 279 ms
| self time by module | ms |
| --- | ---: |
| (native) | 122.3 |
| npm:next | 57.1 |
| node:internal/modules/cjs/loader | 41.7 |
| node:internal/bootstrap/realm | 24.7 |
| node:fs | 5.3 |
| node:internal/deps/undici/undici | 5.3 |
| node:internal/modules/package_json_reader | 4.5 |
| node:path | 4 |
| node:internal/streams/destroy | 1.5 |
| node:internal/deps/cjs-module-lexer/lexer | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
### at 339 ms, 324 ms
| self time by module | ms |
| --- | ---: |
| (native) | 87.6 |
| node:internal/modules/cjs/loader | 54.2 |
| npm:@prisma/client | 38 |
| wasm://wasm/00d1cb12 | 36 |
| node:internal/modules/esm/utils | 26.7 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 24.9 |
| npm:next | 22.1 |
| npm:zod | 6 |
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 3.1 |
| npm:@prisma/client-runtime-utils | 3 |
| inclusive, application modules | ms |
| --- | ---: |
| src/instrumentation.ts | 80.6 |
| src/lib/db-session.ts | 60.7 |
| src/lib/notifications/types.ts | 13.2 |
| src/lib/boot/readiness-summary.ts | 13.2 |
| src/lib/ai/local-host-allowlist.ts | 13.2 |
| src/generated/prisma/internal/class.ts | 7.3 |
| src/lib/logging/context.ts | 6 |
| src/lib/crypto.ts | 6 |
| src/lib/boot/key-check.ts | 6 |
| src/lib/validations/measurement.ts | 3 |
### at 698 ms, 753 ms
| self time by module | ms |
| --- | ---: |
| node:internal/modules/cjs/loader | 178.2 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 177.4 |
| (native) | 83.8 |
| npm:zod | 39.5 |
| npm:next | 34.6 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 33.9 |
| npm:mmdb-lib | 26.1 |
| node:fs | 23.5 |
| npm:yaml | 22.4 |
| src/lib/activity/goal-bounds.ts | 18.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/instrumentation.ts | 410 |
| src/lib/jobs/reminder/register-maintenance.ts | 279.5 |
| src/lib/sharing/acting-session.ts | 138.5 |
| src/lib/jobs/offhost-backup.ts | 137.1 |
| src/lib/integrations/oauth-refresh.ts | 136.3 |
| src/lib/jobs/morning-digest-refresh-shared.ts | 136.3 |
| src/lib/export/environment-backup.ts | 134.1 |
| src/lib/export/full-backup-payload.ts | 134.1 |
| src/app/api/health/route.ts | 127.5 |
| src/lib/insights/snapshot-hash.ts | 124.9 |
## Self time by module until the worker is ready
| module | ms |
| --- | ---: |
| (native) | 524.6 |
| node:internal/modules/cjs/loader | 274 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 202.3 |
| npm:next | 136.8 |
| wasm://wasm/00d1cb12 | 108.1 |
| npm:@prisma/client | 76.1 |
| npm:pg | 56.5 |
| npm:zod | 45.5 |
| node:internal/modules/esm/utils | 41.4 |
| npm:luxon | 38.5 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 35.3 |
| node:fs | 31.7 |
| node:internal/bootstrap/realm | 28.9 |
| npm:pg-protocol | 27.7 |
| npm:mmdb-lib | 26.1 |
| npm:yaml | 22.4 |
| npm:pg-boss | 19.3 |
| src/lib/activity/goal-bounds.ts | 18.5 |
| node:internal/process/task_queues | 17.7 |
| node:internal/streams/readable | 15.5 |
| node:internal/async_hooks | 15.1 |
| npm:cron-parser | 14.6 |
| node:internal/timers | 12.8 |
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 12.4 |
| node:buffer | 11.6 |
## Heap snapshot after 300 s idle (live heap after a full GC)
172.3 MB in 1412114 nodes; module source strings over 50 KB: 44.2 MB.
- parsed locale catalogs: 32 objects, 14.1 MB retained
| node type | MB |
| --- | ---: |
| string | 79.1 |
| array | 27.6 |
| code | 18.6 |
| object | 16.7 |
| hidden | 9.3 |
| native | 8 |
| object shape | 5.8 |
| closure | 5 |
| concatenated string | 1.3 |
| number | 0.4 |
| regexp | 0.2 |
| sliced string | 0.2 |
| synthetic | 0 |
| symbol | 0 |
| bigint | 0 |
| constructor | MB | count |
| --- | ---: | ---: |
| array:(object elements) | 20.6 | 24726 |
| object:Object | 11.9 | 300844 |
| native:system / JSArrayBufferData | 7.5 | 169 |
| array:(object properties) | 4.4 | 6540 |
| array: | 2.7 | 9578 |
| object:system / Context | 1.6 | 28887 |
| closure: | 1.5 | 27081 |
| object:Date1 | 1 | 10632 |
| closure:native_bind | 0.9 | 20134 |
| object:Array | 0.6 | 19550 |
| object:a | 0.4 | 3880 |
| native:Node / histogram | 0.4 | 1 |
| closure:get | 0.3 | 4717 |
| object:Set | 0.2 | 5102 |
| closure:set | 0.2 | 3414 |
| object:d | 0.2 | 3874 |
| object:Module | 0.1 | 1907 |
| object:Promise | 0.1 | 1590 |
| closure:n | 0.1 | 1400 |
| closure:i | 0.1 | 1390 |
| largest retained subtrees | MB | retaining path |
| --- | ---: | --- |
| object:yr | 33.9 | [1] > [10] > 15 > extension > global > prisma > [1] > _engine |
| object:Object | 10 | tX > onClose > onClose > context > system / Context > previous > system / Context > e > Context > __proto__ > c |
| code:.next/server/chu | 9.8 | system / Context > timerListMap > Object > [120000] > TimersList > _idleNext > Timeout > _onTimeout > > shared > script |
| code:.next/server/chu | 9.7 | Module > _cache > Object > .next/server/chunks/_09yxiz_._.js > Module > exports > Array > [1] > > shared > script |
| object:system / Context | 7.4 | Node / PrincipalRealm > base_object_list > Node / BaseObjectList > [25] > Node / ModuleWrap > native_to_javascript > ModuleWrap > 2 > [4] > context |
| code:.next/server/chu | 5.6 | global > prisma > [1] > t > _engineConfig > Object > compilerWasm > Object > getRuntime > getRuntime > shared > script |
| code:.next/server/chu | 5.6 | Module > _cache > Object > .next/server/chunks/_0hfea7u._.js > Module > exports > Array > [1] > > shared > script |
| object:Generator | 4.4 | [6] > principal_realm > Node / PrincipalRealm > base_object_list > Node / BaseObjectList > [26] > Node / ModuleWrap > native_to_javascript > ModuleWrap > 2 > [4 |
| string:AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/ | 4.4 | [1] > [21] > 59867 |
| code:.next/server/chu | 3.7 | [1] > [10] > 15 > extension > global > __healthlog_pgboss__ > PgBoss > _events > Object > error > > shared > script |
| object:Client | 3.6 | Object > close > > context > system / Context > self > Connection > _events > Object > parseComplete > bound_this |
| largest strings | MB |
| --- | ---: |
| module.exports=[425781,(e,a,i)=>{a.exports=JSON.parse('{"common":{"listCapped":"Hier stehe | 9.7 |
| module.exports=[157893,(e,a,i)=>{a.exports=JSON.parse('{"common":{"listCapped":"Hier stehe | 9.7 |
| module.exports=[17856,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=>i | 5.6 |
| module.exports=[234494,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=> | 5.6 |
| const wasm = "AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/fwBgBX9/f39/AGAB | 4.4 |
| AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/fwBgBX9/f39/AGABfwF/YAZ/f39/f3 | 4.4 |
| module.exports=[799092,(e,t,n)=>{"use strict";Object.defineProperty(n,"__esModule",{value: | 3.7 |
| lliADOAUngIEAACgKwAgBgAAoSsAIAcAAKErACAIAACiKwAgCQAAoysAIAoAAKQrACALAAClKwAgDAAApisAIA0AAK | 1.9 |
| (()=>{var e,t,r={"./dist/build/webpack/alias/react-dom-server.js"(e,t,r){"use strict";var | 1.3 |
| module.exports=[842865,(e,t,r)=>{let i=e.r(522734);t.exports=function(e){return e?/-----BE | 1.2 |
| module.exports=[834886,(e,t,n)=>{"use strict";Object.defineProperty(n,"__esModule",{value: | 1.1 |
| // v1.4.34 web-freeze. Schema is locked additive-only until iOS launch // + v1.5.0 marker. | 1.1 |
| module.exports=[971743,(A,e,t)=>{"use strict";let r=A.r(57328),{addAbortListener:s}=A.r(68 | 1.1 |
| module.exports=[835717,(A,e,t)=>{"use strict";let r=A.r(57328),{addAbortListener:s}=A.r(68 | 1 |
| module.exports=[594108,806176,674750,98746,t=>{"use strict";var i;let r;function n(t){retu | 0.7 |
+254
View File
@@ -0,0 +1,254 @@
{
"meta": {
"label": "v1.45.0-before",
"commit": "1915f9152",
"date": "2026-10-22T05:51:47.626Z",
"node": "v22.23.2",
"postgres": "16.15",
"cpus": 14,
"loadAverage": [
29.9,
21.9,
18.9
],
"measurements": 1265561,
"iterations": 20,
"warmup": 3,
"heapMb": 792,
"idleStatementsPerSecond": 0,
"batch5k": {
"totalMs": 1730,
"totalStatements": 534
}
},
"results": [
{
"name": "/api/auth/me",
"mode": "cold",
"request": "GET /api/auth/me",
"n": 20,
"p50Ms": 9.2,
"p95Ms": 11.6,
"maxMs": 13.3,
"statementsMedian": 19,
"statementsMax": 19,
"failures": 0
},
{
"name": "/api/auth/me",
"mode": "warm",
"request": "GET /api/auth/me",
"n": 20,
"p50Ms": 8.6,
"p95Ms": 10.5,
"maxMs": 13,
"statementsMedian": 19,
"statementsMax": 19,
"failures": 0
},
{
"name": "dashboard summary",
"mode": "cold",
"request": "GET /api/dashboard/summary",
"n": 20,
"p50Ms": 170.2,
"p95Ms": 220.5,
"maxMs": 228.3,
"statementsMedian": 31,
"statementsMax": 34,
"failures": 0
},
{
"name": "dashboard summary",
"mode": "warm",
"request": "GET /api/dashboard/summary",
"n": 20,
"p50Ms": 6,
"p95Ms": 6.7,
"maxMs": 6.8,
"statementsMedian": 7,
"statementsMax": 7,
"failures": 0
},
{
"name": "dashboard snapshot",
"mode": "cold",
"request": "GET /api/dashboard/snapshot",
"n": 20,
"p50Ms": 1481.8,
"p95Ms": 1526.2,
"maxMs": 1530,
"statementsMedian": 158,
"statementsMax": 161,
"failures": 0
},
{
"name": "dashboard snapshot",
"mode": "warm",
"request": "GET /api/dashboard/snapshot",
"n": 20,
"p50Ms": 7,
"p95Ms": 7.9,
"maxMs": 10.2,
"statementsMedian": 13,
"statementsMax": 13,
"failures": 0
},
{
"name": "series pulse 30 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=30",
"n": 20,
"p50Ms": 24.4,
"p95Ms": 30.8,
"maxMs": 31.1,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "series pulse 30 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=30",
"n": 20,
"p50Ms": 20.5,
"p95Ms": 29.4,
"maxMs": 30.2,
"statementsMedian": 8,
"statementsMax": 9,
"failures": 0
},
{
"name": "series pulse 365 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=365",
"n": 20,
"p50Ms": 184.1,
"p95Ms": 192.6,
"maxMs": 201.1,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "series pulse 365 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=365",
"n": 20,
"p50Ms": 178.1,
"p95Ms": 186.5,
"maxMs": 187.6,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "series pulse 3650 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=3650",
"n": 20,
"p50Ms": 1627,
"p95Ms": 1664.5,
"maxMs": 1675.7,
"statementsMedian": 8,
"statementsMax": 9,
"failures": 0
},
{
"name": "series pulse 3650 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=3650",
"n": 20,
"p50Ms": 2090.9,
"p95Ms": 2435.4,
"maxMs": 2529.7,
"statementsMedian": 8,
"statementsMax": 9,
"failures": 0
},
{
"name": "day view",
"mode": "cold",
"request": "GET /api/day/2026-10-21",
"n": 20,
"p50Ms": 76.7,
"p95Ms": 93.6,
"maxMs": 116.2,
"statementsMedian": 49,
"statementsMax": 49,
"failures": 0
},
{
"name": "day view",
"mode": "warm",
"request": "GET /api/day/2026-10-21",
"n": 20,
"p50Ms": 62.8,
"p95Ms": 72,
"maxMs": 73.5,
"statementsMedian": 48,
"statementsMax": 48,
"failures": 0
},
{
"name": "insights health status",
"mode": "cold",
"request": "GET /api/insights/health-status",
"n": 20,
"p50Ms": 244.7,
"p95Ms": 401.2,
"maxMs": 404.1,
"statementsMedian": 68,
"statementsMax": 69,
"failures": 0
},
{
"name": "insights health status",
"mode": "warm",
"request": "GET /api/insights/health-status",
"n": 20,
"p50Ms": 238.9,
"p95Ms": 399.1,
"maxMs": 476.6,
"statementsMedian": 68,
"statementsMax": 68,
"failures": 0
},
{
"name": "insights correlations",
"mode": "cold",
"request": "GET /api/insights/correlations",
"n": 20,
"p50Ms": 105.6,
"p95Ms": 326.9,
"maxMs": 349.7,
"statementsMedian": 49,
"statementsMax": 52,
"failures": 0
},
{
"name": "insights correlations",
"mode": "warm",
"request": "GET /api/insights/correlations",
"n": 20,
"p50Ms": 11.3,
"p95Ms": 16.6,
"maxMs": 20.3,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "batch ingest 5k (per 500)",
"mode": "write",
"request": "POST /api/measurements/batch ×10",
"n": 10,
"p50Ms": 166.1,
"p95Ms": 220.3,
"maxMs": 220.3,
"statementsMedian": 53,
"statementsMax": 56,
"failures": 0
}
]
}
+25
View File
@@ -0,0 +1,25 @@
# Benchmark v1.45.0-before
Commit 1915f9152 (the v1.44 runtime this branch starts from), 2026-10-22T05:51:47.626Z. 1265561 measurement rows on the bench account, 20 measured requests per row after 3 warm-up requests, one at a time. Server: production build, web process, heap ceiling 792 MB. Postgres 16.15, 14 cores, load average 29.9,21.9,18.9 at the end. Idle background: 0 statements/s.
| Request | Mode | n | p50 ms | p95 ms | max ms | statements (median) | statements (max) |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
| /api/auth/me | cold | 20 | 9.2 | 11.6 | 13.3 | 19 | 19 |
| /api/auth/me | warm | 20 | 8.6 | 10.5 | 13 | 19 | 19 |
| dashboard summary | cold | 20 | 170.2 | 220.5 | 228.3 | 31 | 34 |
| dashboard summary | warm | 20 | 6 | 6.7 | 6.8 | 7 | 7 |
| dashboard snapshot | cold | 20 | 1481.8 | 1526.2 | 1530 | 158 | 161 |
| dashboard snapshot | warm | 20 | 7 | 7.9 | 10.2 | 13 | 13 |
| series pulse 30 d | cold | 20 | 24.4 | 30.8 | 31.1 | 8 | 8 |
| series pulse 30 d | warm | 20 | 20.5 | 29.4 | 30.2 | 8 | 9 |
| series pulse 365 d | cold | 20 | 184.1 | 192.6 | 201.1 | 8 | 8 |
| series pulse 365 d | warm | 20 | 178.1 | 186.5 | 187.6 | 8 | 8 |
| series pulse 3650 d | cold | 20 | 1627 | 1664.5 | 1675.7 | 8 | 9 |
| series pulse 3650 d | warm | 20 | 2090.9 | 2435.4 | 2529.7 | 8 | 9 |
| day view | cold | 20 | 76.7 | 93.6 | 116.2 | 49 | 49 |
| day view | warm | 20 | 62.8 | 72 | 73.5 | 48 | 48 |
| insights health status | cold | 20 | 244.7 | 401.2 | 404.1 | 68 | 69 |
| insights health status | warm | 20 | 238.9 | 399.1 | 476.6 | 68 | 68 |
| insights correlations | cold | 20 | 105.6 | 326.9 | 349.7 | 49 | 52 |
| insights correlations | warm | 20 | 11.3 | 16.6 | 20.3 | 8 | 8 |
| batch ingest 5k (per 500) | write | 10 | 166.1 | 220.3 | 220.3 | 53 | 56 |
File diff suppressed because it is too large. Load diff
+298
View File
@@ -0,0 +1,298 @@
# Boot profile v1.45.0-boot-after
Commit a859adb59, 2026-10-22T04:49:50.419Z. Production build, every role, heap ceiling 792 MB, 300 s idle. 14 cores, load average 8.6,9.4,10.7 at the end.
- first answer from /api/health: 850 ms; background role ready: 3038 ms
- event-loop stalls logged: 0, max 0 ms
## Stalls
## Heap samples
| at s | used MB | old | large obj | code | external | array buffers | rss |
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| 6 | 166 | 91 | 53 | 3 | 12 | 1 | 430 |
| 306 | 153 | 90 | 53 | 4 | 11 | 0 | 599 |
## Full collections (mark-compact)
10 mark-compacts during the run. Used heap before the collection, and what survived it (the live heap):
| at s | before MB | after MB |
| ---: | ---: | ---: |
| 0 | 20.2 | 17.1 |
| 0 | 55.3 | 52.6 |
| 0 | 83.2 | 81.1 |
| 1 | 148.4 | 132.7 |
| 16 | 159.6 | 153.2 |
| 17 | 153.4 | 151 |
| 62 | 167.5 | 157.5 |
| 246 | 183.4 | 152.6 |
| 303 | 165.6 | 151.8 |
| 303 | 151.8 | 151.8 |
## Main-thread busy stretches over 50 ms without an idle sample, first two minutes
### at 6 ms, 175 ms
| self time by module | ms |
| --- | ---: |
| npm:next | 47.2 |
| node:internal/modules/cjs/loader | 34.8 |
| (native) | 34.5 |
| node:internal/bootstrap/realm | 25.7 |
| node:internal/deps/undici/undici | 8.8 |
| node:path | 6.3 |
| node:internal/modules/package_json_reader | 3 |
| node:internal/net | 3 |
| node:internal/modules/esm/resolve | 2.5 |
| node:fs | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
### at 203 ms, 241 ms
| self time by module | ms |
| --- | ---: |
| (native) | 52.7 |
| node:internal/modules/cjs/loader | 40.1 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 31.7 |
| wasm://wasm/00d1cb12 | 28.7 |
| npm:@prisma/client | 28 |
| npm:next | 16.3 |
| node:internal/modules/esm/utils | 12 |
| node:fs | 4.5 |
| npm:zod | 3 |
| node:internal/modules/helpers | 3 |
| inclusive, application modules | ms |
| --- | ---: |
| src/instrumentation.ts | 80.8 |
| src/lib/db-session.ts | 49.5 |
| src/lib/boot/startup-state.ts | 25.6 |
| src/lib/boot/boot-steps.ts | 25.6 |
| src/lib/logging/background.ts | 25.6 |
| src/lib/process-type.ts | 25.5 |
| src/lib/logging/context.ts | 13.6 |
| src/lib/validations/measurement.ts | 12.1 |
| src/lib/logging/redact.ts | 10.5 |
| src/lib/validations/entry-instant.ts | 4.6 |
### at 501 ms, 178 ms
| self time by module | ms |
| --- | ---: |
| node:internal/modules/cjs/loader | 53.9 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 37.5 |
| npm:next | 18 |
| npm:zod | 12 |
| node:internal/modules/esm/utils | 10.5 |
| (native) | 8.7 |
| node:fs | 6 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 4.5 |
| npm:undici | 4.5 |
| node:internal/url | 1.8 |
| inclusive, application modules | ms |
| --- | ---: |
| src/app/api/health/route.ts | 61.4 |
| src/lib/api-errors.ts | 27 |
| src/lib/jobs/job-failures.ts | 14.9 |
| src/lib/jobs/offhost-backup.ts | 14.9 |
| src/lib/managed-profiles/handover-access.ts | 14.9 |
| src/lib/sharing/acting-session.ts | 14.9 |
| src/lib/db-session.ts | 13.4 |
| src/lib/export/environment-backup.ts | 13.4 |
| src/lib/export/full-backup-payload.ts | 13.4 |
| src/lib/logging/background.ts | 10.5 |
### at 833 ms, 184 ms
| self time by module | ms |
| --- | ---: |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 38.9 |
| node:internal/modules/cjs/loader | 28.6 |
| (native) | 22.8 |
| src/lib/i18n/server-catalogs.ts | 15.1 |
| npm:yaml | 15 |
| node:fs | 12.1 |
| npm:zod | 9 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 5.9 |
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 4.5 |
| npm:semver | 3 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/jobs/reminder-worker.ts | 129.1 |
| src/lib/jobs/reminder/google-health-sync.ts | 88.5 |
| src/lib/whoop/sync.ts | 79.5 |
| src/lib/whoop/sync-body.ts | 63 |
| src/lib/insights/bucket-series.ts | 60 |
| src/lib/whoop/oauth-descriptor.ts | 60 |
| src/lib/whoop/sync-core.ts | 60 |
| src/instrumentation.ts | 43.4 |
| src/lib/import/unzip-export-xml.ts | 31.4 |
| src/lib/jobs/integration-status-null-probe.ts | 31.4 |
### at 1099 ms, 54 ms
| self time by module | ms |
| --- | ---: |
| node:internal/modules/cjs/loader | 23.9 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 10.6 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 5.8 |
| src/lib/daily/today-overview.ts | 4.5 |
| src/lib/jobs/reminder-worker.ts | 3 |
| node:fs | 3 |
| npm:pg-boss | 1.5 |
| npm:zod | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/jobs/reminder-worker.ts | 52.3 |
| src/lib/jobs/reminder/insights-handlers.ts | 22.3 |
| src/lib/ai/prompts/arrival-reaction.ts | 12 |
| src/lib/daily/score-steady.ts | 10.5 |
| src/lib/dashboard/bands.ts | 6 |
| src/lib/jobs/data-arrival.ts | 4.5 |
| src/lib/dashboard/snapshot-read.ts | 4.5 |
| src/lib/daily/today-overview.ts | 4.5 |
| src/lib/daily/priority-item-key.ts | 4.5 |
| src/lib/ai/prompts/status-batch.ts | 3 |
### at 1309 ms, 51 ms
| self time by module | ms |
| --- | ---: |
| node:internal/modules/cjs/loader | 28 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 6 |
| node:fs | 4.5 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 3 |
| turbopack:///[turbopack]/nodejs/runtime/runtime-base.ts | 1.5 |
| src/lib/jobs/failure-condition.ts | 1.5 |
| src/lib/labs/parse-reference-range.ts | 1.5 |
| npm:zod | 1.5 |
| node:internal/linkedlist | 1.5 |
| (native) | 0.9 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/jobs/reminder-worker.ts | 48.2 |
| src/lib/jobs/reminder/ops-handlers.ts | 14.1 |
| src/lib/medications/ingredients/label-extract.ts | 3 |
| src/lib/export/restore-backup.ts | 2.1 |
| src/lib/jobs/failure-condition.ts | 1.5 |
| src/lib/documents/ai-runs/summary-run.ts | 1.5 |
| src/lib/documents/auto-stage-labs.ts | 1.5 |
| src/lib/documents/ai-runs/store.ts | 1.5 |
| src/lib/labs/parse-reference-range.ts | 1.5 |
| src/lib/labs/ocr-upload.ts | 1.5 |
## Self time by module until the worker is ready
| module | ms |
| --- | ---: |
| (native) | 230.8 |
| node:internal/modules/cjs/loader | 223.6 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 124.7 |
| npm:next | 98.8 |
| wasm://wasm/00d1cb12 | 62.6 |
| npm:@prisma/client | 56.2 |
| node:fs | 34.6 |
| node:internal/bootstrap/realm | 28.7 |
| npm:pg | 28.3 |
| npm:zod | 27 |
| npm:pg-boss | 24.6 |
| node:internal/modules/esm/utils | 22.5 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 20.6 |
| npm:luxon | 17 |
| src/lib/i18n/server-catalogs.ts | 15.1 |
| npm:yaml | 15 |
| npm:pg-pool | 13 |
| node:internal/deps/undici/undici | 10.4 |
| node:path | 7.8 |
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 7.5 |
| node:internal/async_hooks | 7.5 |
| node:internal/process/task_queues | 7.3 |
| node:internal/modules/package_json_reader | 6 |
| npm:undici | 6 |
| npm:pg-protocol | 6 |
## Heap snapshot after 300 s idle (live heap after a full GC)
160.1 MB in 1275329 nodes; module source strings over 50 KB: 39.8 MB.
- parsed locale catalogs: 8 objects, 7 MB retained
| node type | MB |
| --- | ---: |
| string | 68.7 |
| array | 26.4 |
| code | 21.1 |
| object | 14.1 |
| hidden | 8.9 |
| native | 8 |
| object shape | 5.9 |
| closure | 5.2 |
| concatenated string | 1.3 |
| regexp | 0.2 |
| sliced string | 0.2 |
| number | 0.1 |
| synthetic | 0 |
| symbol | 0 |
| bigint | 0 |
| constructor | MB | count |
| --- | ---: | ---: |
| array:(object elements) | 20.4 | 23797 |
| object:Object | 10.2 | 274231 |
| native:system / JSArrayBufferData | 7.6 | 171 |
| array:(object properties) | 3.6 | 6512 |
| array: | 2.4 | 10502 |
| closure: | 1.6 | 29042 |
| object:system / Context | 1.6 | 28873 |
| closure:native_bind | 1 | 20782 |
| object:Array | 0.6 | 19679 |
| object:a | 0.4 | 4438 |
| native:Node / histogram | 0.4 | 1 |
| closure:get | 0.3 | 4736 |
| object:d | 0.3 | 4425 |
| object:Set | 0.2 | 5673 |
| closure:set | 0.2 | 3432 |
| object:Module | 0.2 | 1958 |
| closure:i | 0.1 | 1581 |
| closure:n | 0.1 | 1572 |
| object:Promise | 0.1 | 1277 |
| object:n | 0.1 | 1881 |
| largest retained subtrees | MB | retaining path |
| --- | ---: | --- |
| object:t | 33.9 | [1] > [10] > 60 > extension > global > prisma > [1] |
| object:global | 7.9 | [1] > [10] > 60 > extension |
| object:system / Context | 7.4 | Node / PrincipalRealm > base_object_list > Node / BaseObjectList > [25] > Node / ModuleWrap > native_to_javascript > ModuleWrap > 2 > [4] > context |
| object:Module | 5.6 | [1] > [10] > 97 > process > nextTick > patchedNextTick > context > system / Context > require > require > cache > .n |
| code:.next/server/chu | 5.6 | require > cache > Object > .next/server/chunks/_1kwfnjg._.js > Module > exports > Array > [1] > > shared > script |
| code:.next/server/chu | 5.6 | global > prisma > [1] > t > _engineConfig > Object > compilerWasm > Object > getRuntime > getRuntime > shared > script |
| object:Generator | 4.4 | [6] > principal_realm > Node / PrincipalRealm > base_object_list > Node / BaseObjectList > [26] > Node / ModuleWrap > native_to_javascript > ModuleWrap > 2 > [4 |
| string:AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/ | 4.4 | [1] > [21] > 62203 |
| object:Object | 3.3 | system / Context > require > require > cache > Object > .next/server/chunks/[turbopack]_runtime.js > Module > export |
| largest strings | MB |
| --- | ---: |
| module.exports=[234494,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=> | 5.6 |
| module.exports=[17856,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=>i | 5.6 |
| module.exports=[17856,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=>i | 5.6 |
| const wasm = "AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/fwBgBX9/f39/AGAB | 4.4 |
| AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/fwBgBX9/f39/AGABfwF/YAZ/f39/f3 | 4.4 |
| lliADOAUngIEAACgKwAgBgAAoSsAIAcAAKErACAIAACiKwAgCQAAoysAIAoAAKQrACALAAClKwAgDAAApisAIA0AAK | 1.9 |
| (()=>{var e,t,r={"./dist/build/webpack/alias/react-dom-server.js"(e,t,r){"use strict";var | 1.3 |
| module.exports=[842865,(e,t,r)=>{let i=e.r(522734);t.exports=function(e){return e?/-----BE | 1.2 |
| module.exports=[842865,(e,t,r)=>{let i=e.r(522734);t.exports=function(e){return e?/-----BE | 1.2 |
| module.exports=[799092,(e,t,n)=>{"use strict";Object.defineProperty(n,"__esModule",{value: | 1.1 |
| // v1.4.34 web-freeze. Schema is locked additive-only until iOS launch // + v1.5.0 marker. | 1.1 |
| module.exports=[971743,(A,e,t)=>{"use strict";let r=A.r(57328),{addAbortListener:s}=A.r(68 | 1.1 |
| module.exports=[835717,(A,e,t)=>{"use strict";let r=A.r(57328),{addAbortListener:s}=A.r(68 | 1 |
| module.exports=[767189,e=>e.a(async(t,a)=>{try{var n=e.i(64105),r=e.i(563616),i=t([n]);[n] | 0.8 |
| module.exports=[594108,806176,674750,98746,t=>{"use strict";var i;let r;function n(t){retu | 0.7 |
File diff suppressed because it is too large. Load diff
+804
View File
@@ -0,0 +1,804 @@
# Boot profile v1.45.0-boot-final
Commit a57fa8335, 2026-10-22T06:58:52.591Z. Production build, every role, heap ceiling 792 MB, 300 s idle. 14 cores, load average 5.6,6.4,11.1 at the end.
- first answer from /api/health: 1325 ms; background role ready: 3217 ms
- event-loop stalls logged: 0, max 0 ms
## Stalls
## Heap samples
| at s | used MB | old | large obj | code | external | array buffers | rss |
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| 6 | 225 | 158 | 54 | 4 | 11 | 0 | 500 |
| 306 | 158 | 96 | 53 | 5 | 11 | 0 | 788 |
## Full collections (mark-compact)
98 mark-compacts during the run. Used heap before the collection, and what survived it (the live heap):
| at s | before MB | after MB |
| ---: | ---: | ---: |
| 0 | 21.5 | 17.5 |
| 8 | 203.8 | 146.5 |
| 24 | 236.5 | 142.5 |
| 41 | 223.8 | 144.9 |
| 60 | 225.8 | 147.5 |
| 85 | 224.4 | 147.7 |
| 124 | 225.4 | 151.2 |
| 167 | 176.7 | 151 |
| 209 | 230.1 | 150.4 |
| 242 | 197.9 | 151.5 |
| 273 | 224.8 | 152.2 |
| 302 | 177.9 | 152.5 |
| 312 | 170.5 | 152.9 |
| 327 | 170.6 | 152 |
| 338 | 169.7 | 151.8 |
## Main-thread busy stretches over 50 ms without an idle sample, first two minutes
### at 5 ms, 396 ms
| self time by module | ms |
| --- | ---: |
| (native) | 230.2 |
| node:internal/modules/cjs/loader | 57.8 |
| npm:next | 52.3 |
| node:internal/bootstrap/realm | 29.3 |
| node:internal/deps/undici/undici | 8.9 |
| node:internal/modules/esm/loader | 4.5 |
| node:fs | 3 |
| node:internal/net | 2.5 |
| node:internal/bootstrap/switches/is_main_thread | 2 |
| node:os | 1.8 |
| inclusive, application modules | ms |
| --- | ---: |
### at 472 ms, 336 ms
| self time by module | ms |
| --- | ---: |
| (native) | 121.3 |
| wasm://wasm/00d1cb12 | 43 |
| node:internal/modules/cjs/loader | 40.5 |
| npm:next | 31.7 |
| npm:@prisma/client | 23.8 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 23.4 |
| node:internal/modules/esm/utils | 17.3 |
| npm:zod | 5.9 |
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 3 |
| node:vm | 1.7 |
| inclusive, application modules | ms |
| --- | ---: |
| src/instrumentation.ts | 95.8 |
| src/lib/db-session.ts | 47.3 |
| src/lib/process-type.ts | 27.1 |
| src/lib/boot/startup-state.ts | 25.5 |
| src/lib/boot/boot-steps.ts | 25.5 |
| src/lib/logging/background.ts | 25.5 |
| src/lib/logging/redact.ts | 13 |
| src/lib/logging/context.ts | 11 |
| src/lib/validations/measurement.ts | 10 |
| src/generated/prisma/internal/class.ts | 4.7 |
### at 811 ms, 225 ms
| self time by module | ms |
| --- | ---: |
| node:internal/modules/cjs/loader | 52.3 |
| (native) | 48.2 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 37 |
| npm:next | 22.2 |
| npm:zod | 12.5 |
| node:internal/modules/esm/utils | 8.7 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 5.6 |
| node:fs | 4.5 |
| node:internal/async_hooks | 3 |
| node:internal/modules/esm/translators | 2.6 |
| inclusive, application modules | ms |
| --- | ---: |
| src/app/api/health/route.ts | 62 |
| src/lib/api-errors.ts | 25.9 |
| src/lib/auth/secure-cookie.ts | 14.9 |
| src/lib/logging/background.ts | 13.4 |
| src/lib/sharing/acting-session.ts | 12.8 |
| src/lib/validations/measurement.ts | 11.9 |
| src/lib/db-session.ts | 11.3 |
| src/lib/export/environment-backup.ts | 11.3 |
| src/lib/export/full-backup-payload.ts | 11.3 |
| src/lib/jobs/job-failures.ts | 11.3 |
### at 1063 ms, 246 ms
| self time by module | ms |
| --- | ---: |
| (native) | 64.7 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 47.2 |
| node:internal/modules/cjs/loader | 31.6 |
| npm:luxon | 18 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 15.3 |
| npm:yaml | 15 |
| npm:zod | 10.2 |
| src/lib/i18n/server-catalogs.ts | 8.2 |
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 4.5 |
| npm:cron-parser | 4 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/jobs/reminder-worker.ts | 137.6 |
| src/lib/jobs/reminder/google-health-sync.ts | 78 |
| src/lib/whoop/sync.ts | 65.9 |
| src/instrumentation.ts | 53.8 |
| src/lib/insights/bucket-series.ts | 47.1 |
| src/lib/whoop/oauth-descriptor.ts | 47.1 |
| src/lib/whoop/sync-core.ts | 47.1 |
| src/lib/whoop/sync-body.ts | 47.1 |
| src/lib/import/unzip-export-xml.ts | 35.3 |
| src/lib/jobs/integration-status-null-probe.ts | 35.3 |
### at 1363 ms, 68 ms
| self time by module | ms |
| --- | ---: |
| node:internal/modules/cjs/loader | 29.2 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 6.7 |
| (native) | 5.9 |
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 4.5 |
| src/lib/daily/today-overview.ts | 4.1 |
| node:fs | 3 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 3 |
| node:internal/streams/writable | 1.5 |
| src/lib/jobs/reminder/insights-handlers.ts | 1.5 |
| npm:next | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/jobs/reminder-worker.ts | 62.5 |
| src/lib/jobs/reminder/insights-handlers.ts | 24.4 |
| src/lib/ai/prompts/arrival-reaction.ts | 12.8 |
| src/lib/daily/score-steady.ts | 11.3 |
| src/lib/dashboard/bands.ts | 7.1 |
| src/lib/dashboard/snapshot-read.ts | 5.8 |
| src/lib/jobs/data-arrival.ts | 4.5 |
| src/lib/daily/today-overview.ts | 4.1 |
| src/lib/daily/priority-item-key.ts | 4.1 |
| src/lib/ai/prompts/status-batch.ts | 3 |
### at 1545 ms, 57 ms
| self time by module | ms |
| --- | ---: |
| node:internal/modules/cjs/loader | 25.3 |
| (native) | 12.9 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 7.2 |
| node:fs | 4.2 |
| node:buffer | 1.5 |
| npm:zod | 1.5 |
| src/lib/jobs/pr-detection.ts | 1.5 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 1.5 |
| src/lib/jobs/measurement-maintenance.ts | 1.3 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/jobs/reminder-worker.ts | 55.3 |
| src/lib/jobs/reminder/ops-handlers.ts | 13 |
| src/lib/export/restore-backup.ts | 3 |
| src/lib/medications/ingredients/label-extract.ts | 3 |
| src/lib/environment/service.ts | 1.5 |
| src/lib/export/backup-blob.ts | 1.5 |
| src/lib/jobs/pr-detection.ts | 1.5 |
| src/lib/jobs/document-content-index-backfill.ts | 1.5 |
| src/lib/labs/ocr-extract.ts | 1.5 |
| src/lib/jobs/reminder/backup-handlers.ts | 1.5 |
### at 1820 ms, 114 ms
| self time by module | ms |
| --- | ---: |
| src/lib/tz/zone-offset.ts | 36 |
| npm:@prisma/client | 18.2 |
| src/lib/validations/source-priority.ts | 18.1 |
| (native) | 13.1 |
| src/lib/personal-records/pr-detection-worker.ts | 7.2 |
| node:buffer | 4.1 |
| src/lib/analytics/source-priority.ts | 3.7 |
| npm:zod | 2.3 |
| npm:pg | 2 |
| src/lib/tz/format.ts | 1.9 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 73.3 |
| src/lib/tz/format.ts | 40.7 |
| src/lib/measurements/consolidation-tz.ts | 40.7 |
| src/lib/tz/zone-offset.ts | 38.8 |
| src/lib/measurements/consolidation-base.ts | 38.1 |
| src/lib/analytics/source-priority.ts | 28.1 |
| src/lib/validations/source-priority.ts | 20.4 |
| src/lib/tz/intl-cache.ts | 1.5 |
### at 2407 ms, 50 ms
| self time by module | ms |
| --- | ---: |
| (native) | 18.9 |
| src/lib/validations/source-priority.ts | 14.1 |
| src/lib/analytics/source-priority.ts | 3.7 |
| src/lib/personal-records/pr-detection-worker.ts | 3 |
| npm:next | 1.8 |
| npm:%40prisma | 1.5 |
| src/lib/tz/format.ts | 1.5 |
| node:internal/async_local_storage/async_hooks | 1.5 |
| npm:pg | 1.5 |
| npm:@prisma/client | 1.3 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 26.6 |
| src/lib/analytics/source-priority.ts | 19.3 |
| src/lib/validations/source-priority.ts | 14.1 |
| src/lib/tz/format.ts | 3 |
| src/lib/measurements/consolidation-tz.ts | 3 |
| src/lib/measurements/consolidation-base.ts | 2.8 |
| src/lib/tz/zone-offset.ts | 1.5 |
### at 18192 ms, 59 ms
| self time by module | ms |
| --- | ---: |
| (native) | 15.5 |
| npm:@prisma/client | 13.9 |
| src/lib/validations/source-priority.ts | 11.8 |
| npm:pg | 3.6 |
| node:events | 3 |
| node:buffer | 2.5 |
| src/lib/measurements/consolidation-base.ts | 1.5 |
| src/lib/personal-records/pr-detection-worker.ts | 1.5 |
| node:internal/async_hooks | 1.5 |
| npm:pg-types | 1.4 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 18.9 |
| src/lib/analytics/source-priority.ts | 12.9 |
| src/lib/validations/source-priority.ts | 11.8 |
| src/lib/measurements/consolidation-base.ts | 4.5 |
| src/lib/tz/zone-offset.ts | 2.6 |
| src/lib/tz/format.ts | 2.6 |
| src/lib/measurements/consolidation-tz.ts | 2.6 |
### at 19914 ms, 65 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 18.1 |
| (native) | 12.3 |
| npm:@prisma/client | 11.6 |
| npm:next | 5.9 |
| src/lib/personal-records/pr-detection-worker.ts | 3 |
| src/lib/tz/format.ts | 3 |
| npm:pg | 2.6 |
| npm:pg-protocol | 2.5 |
| node:internal/async_hooks | 1.5 |
| src/lib/analytics/source-priority.ts | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 29.8 |
| src/lib/analytics/source-priority.ts | 22.6 |
| src/lib/validations/source-priority.ts | 18.1 |
| src/lib/tz/format.ts | 5.8 |
| src/lib/measurements/consolidation-tz.ts | 5.8 |
| src/lib/tz/zone-offset.ts | 2.8 |
| src/lib/measurements/consolidation-base.ts | 2.8 |
### at 20296 ms, 55 ms
| self time by module | ms |
| --- | ---: |
| (native) | 22.4 |
| npm:@prisma/client | 14.8 |
| node:buffer | 3 |
| npm:zod | 3 |
| src/lib/measurements/consolidation-base.ts | 3 |
| src/lib/tz/format.ts | 1.5 |
| node:internal/stream_base_commons | 1.5 |
| src/lib/analytics/source-priority.ts | 1.5 |
| src/lib/personal-records/pr-detection-worker.ts | 1.5 |
| npm:pg-protocol | 1.3 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 10.5 |
| src/lib/analytics/source-priority.ts | 6 |
| src/lib/validations/source-priority.ts | 3 |
| src/lib/measurements/consolidation-base.ts | 3 |
| src/lib/tz/format.ts | 1.5 |
| src/lib/measurements/consolidation-tz.ts | 1.5 |
### at 30289 ms, 69 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 18.3 |
| (native) | 12.9 |
| npm:@prisma/client | 8.8 |
| node:buffer | 6.8 |
| src/lib/personal-records/pr-detection-worker.ts | 4.5 |
| src/lib/tz/format.ts | 4 |
| npm:pg-protocol | 3 |
| npm:zod | 3 |
| node:timers | 1.5 |
| node:internal/async_hooks | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 33 |
| src/lib/analytics/source-priority.ts | 24.3 |
| src/lib/validations/source-priority.ts | 21.3 |
| src/lib/tz/format.ts | 5.6 |
| src/lib/measurements/consolidation-tz.ts | 5.6 |
| src/lib/measurements/consolidation-base.ts | 2.6 |
| src/lib/tz/zone-offset.ts | 1.5 |
### at 31004 ms, 68 ms
| self time by module | ms |
| --- | ---: |
| (native) | 17.8 |
| src/lib/validations/source-priority.ts | 14.9 |
| npm:@prisma/client | 7.6 |
| npm:next | 4.5 |
| node:internal/async_hooks | 4.5 |
| npm:pg-protocol | 4.1 |
| npm:pg | 3 |
| src/lib/tz/zone-offset.ts | 3 |
| node:buffer | 2.7 |
| src/lib/analytics/source-priority.ts | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 26.4 |
| src/lib/analytics/source-priority.ts | 19 |
| src/lib/validations/source-priority.ts | 16.2 |
| src/lib/measurements/consolidation-base.ts | 6.1 |
| src/lib/tz/format.ts | 5.8 |
| src/lib/measurements/consolidation-tz.ts | 5.8 |
| src/lib/tz/zone-offset.ts | 4.5 |
### at 35350 ms, 65 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 17 |
| npm:@prisma/client | 11.8 |
| (native) | 8.6 |
| node:buffer | 6.5 |
| src/lib/tz/format.ts | 4.5 |
| npm:pg-protocol | 3 |
| src/lib/personal-records/pr-detection-worker.ts | 3 |
| npm:%40prisma | 1.6 |
| npm:pg | 1.5 |
| src/lib/analytics/source-priority.ts | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 26 |
| src/lib/analytics/source-priority.ts | 20 |
| src/lib/validations/source-priority.ts | 17 |
| src/lib/tz/format.ts | 4.5 |
| src/lib/measurements/consolidation-tz.ts | 4.5 |
| src/lib/measurements/consolidation-base.ts | 3 |
### at 36452 ms, 57 ms
| self time by module | ms |
| --- | ---: |
| (native) | 20 |
| src/lib/validations/source-priority.ts | 16 |
| npm:@prisma/client | 7.3 |
| node:internal/per_context/primordials | 2.8 |
| npm:%40prisma | 1.5 |
| npm:next | 1.5 |
| src/lib/tz/zone-offset.ts | 1.5 |
| node:internal/async_hooks | 1.5 |
| node:internal/streams/readable | 1.5 |
| npm:pg-protocol | 1.2 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 21.2 |
| src/lib/validations/source-priority.ts | 16 |
| src/lib/analytics/source-priority.ts | 16 |
| src/lib/tz/format.ts | 2.5 |
| src/lib/measurements/consolidation-tz.ts | 2.5 |
| src/lib/measurements/consolidation-base.ts | 2.5 |
| src/lib/tz/zone-offset.ts | 1.5 |
### at 38744 ms, 56 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 13.3 |
| (native) | 9.9 |
| npm:@prisma/client | 9.2 |
| node:buffer | 4.7 |
| npm:pg-protocol | 4.3 |
| node:events | 1.9 |
| npm:pg | 1.6 |
| npm:%40prisma | 1.5 |
| npm:next | 1.5 |
| src/lib/tz/format.ts | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 21.8 |
| src/lib/analytics/source-priority.ts | 16 |
| src/lib/validations/source-priority.ts | 13.3 |
| src/lib/measurements/consolidation-base.ts | 4.3 |
| src/lib/tz/format.ts | 3 |
| src/lib/measurements/consolidation-tz.ts | 3 |
| src/lib/tz/zone-offset.ts | 1.4 |
### at 42458 ms, 50 ms
| self time by module | ms |
| --- | ---: |
| npm:@prisma/client | 13.5 |
| src/lib/validations/source-priority.ts | 10.1 |
| (native) | 7.6 |
| npm:pg-protocol | 3.2 |
| node:events | 3 |
| src/lib/personal-records/pr-detection-worker.ts | 3 |
| src/lib/tz/format.ts | 3 |
| npm:pg | 2.8 |
| node:internal/async_hooks | 1.5 |
| npm:%40prisma | 1.4 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 17.3 |
| src/lib/analytics/source-priority.ts | 12.7 |
| src/lib/validations/source-priority.ts | 10.1 |
| src/lib/tz/format.ts | 3 |
| src/lib/measurements/consolidation-tz.ts | 3 |
| src/lib/measurements/consolidation-base.ts | 1.5 |
| src/lib/jobs/job-lock.ts | 1.3 |
| src/lib/jobs/long-pass.ts | 1.3 |
| src/lib/jobs/run-job.ts | 1.3 |
| src/lib/jobs/job-observer.ts | 1.3 |
### at 45451 ms, 63 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 15.8 |
| npm:@prisma/client | 10.5 |
| (native) | 8.4 |
| src/lib/tz/format.ts | 5.3 |
| node:internal/async_local_storage/async_hooks | 4.4 |
| node:buffer | 3 |
| npm:pg | 3 |
| src/lib/personal-records/pr-detection-worker.ts | 3 |
| npm:zod | 3 |
| npm:%40prisma | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 31.7 |
| src/lib/analytics/source-priority.ts | 21.8 |
| src/lib/validations/source-priority.ts | 18.8 |
| src/lib/measurements/consolidation-base.ts | 5.3 |
| src/lib/tz/format.ts | 5.3 |
| src/lib/measurements/consolidation-tz.ts | 5.3 |
### at 56854 ms, 66 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 15 |
| npm:@prisma/client | 13.7 |
| (native) | 6.6 |
| node:buffer | 6.6 |
| npm:next | 6 |
| npm:pg | 4.5 |
| src/lib/personal-records/pr-detection-worker.ts | 4.1 |
| src/lib/tz/format.ts | 3.1 |
| node:internal/process/task_queues | 1.5 |
| node:internal/async_hooks | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 25.3 |
| src/lib/analytics/source-priority.ts | 18.1 |
| src/lib/validations/source-priority.ts | 16.5 |
| src/lib/tz/format.ts | 3.1 |
| src/lib/measurements/consolidation-tz.ts | 3.1 |
| src/lib/measurements/consolidation-base.ts | 3.1 |
### at 56923 ms, 52 ms
| self time by module | ms |
| --- | ---: |
| (native) | 20 |
| npm:@prisma/client | 10.1 |
| node:buffer | 3.7 |
| npm:pg | 3 |
| src/lib/tz/format.ts | 2.6 |
| npm:pg-protocol | 2.6 |
| src/lib/analytics/source-priority.ts | 2.6 |
| src/lib/personal-records/pr-detection-worker.ts | 1.5 |
| src/lib/tz/zone-offset.ts | 1.5 |
| npm:pg-types | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 9.3 |
| src/lib/analytics/source-priority.ts | 5.3 |
| src/lib/tz/format.ts | 4.1 |
| src/lib/measurements/consolidation-tz.ts | 4.1 |
| src/lib/measurements/consolidation-base.ts | 2.6 |
| src/lib/tz/zone-offset.ts | 1.5 |
| src/lib/validations/source-priority.ts | 1.1 |
### at 59508 ms, 56 ms
| self time by module | ms |
| --- | ---: |
| npm:@prisma/client | 15.7 |
| src/lib/validations/source-priority.ts | 9.7 |
| node:buffer | 7 |
| (native) | 6.1 |
| npm:pg-protocol | 4.5 |
| npm:next | 4.1 |
| src/lib/tz/format.ts | 3 |
| src/lib/analytics/source-priority.ts | 3 |
| node:internal/async_hooks | 1.5 |
| src/lib/measurements/consolidation-base.ts | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 21.7 |
| src/lib/analytics/source-priority.ts | 15.8 |
| src/lib/validations/source-priority.ts | 9.7 |
| src/lib/tz/format.ts | 5.9 |
| src/lib/measurements/consolidation-tz.ts | 5.9 |
| src/lib/measurements/consolidation-base.ts | 5.9 |
| src/lib/tz/zone-offset.ts | 2.9 |
### at 61315 ms, 62 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 20.1 |
| npm:@prisma/client | 14.1 |
| (native) | 8.2 |
| npm:pg | 4.5 |
| node:buffer | 3.1 |
| src/lib/personal-records/pr-detection-worker.ts | 3 |
| src/lib/measurements/consolidation-base.ts | 3 |
| npm:pg-protocol | 1.5 |
| npm:next | 1.5 |
| src/lib/analytics/source-priority.ts | 1.4 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 30.3 |
| src/lib/analytics/source-priority.ts | 22.8 |
| src/lib/validations/source-priority.ts | 21.4 |
| src/lib/measurements/consolidation-base.ts | 4.5 |
| src/lib/tz/zone-offset.ts | 1.5 |
| src/lib/tz/format.ts | 1.5 |
| src/lib/measurements/consolidation-tz.ts | 1.5 |
### at 72986 ms, 57 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 19.1 |
| npm:@prisma/client | 10.2 |
| (native) | 6.6 |
| npm:pg-protocol | 5.5 |
| npm:next | 3 |
| src/lib/analytics/source-priority.ts | 3 |
| npm:pg | 2.5 |
| src/lib/personal-records/pr-detection-worker.ts | 1.5 |
| node:internal/async_local_storage/async_hooks | 1.5 |
| src/lib/tz/format.ts | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 29.6 |
| src/lib/analytics/source-priority.ts | 23.5 |
| src/lib/validations/source-priority.ts | 19.1 |
| src/lib/measurements/consolidation-base.ts | 4.6 |
| src/lib/tz/format.ts | 4.5 |
| src/lib/measurements/consolidation-tz.ts | 4.5 |
| src/lib/tz/zone-offset.ts | 3 |
### at 73523 ms, 51 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 12 |
| npm:next | 8.9 |
| npm:@prisma/client | 6 |
| (native) | 4.5 |
| src/lib/analytics/source-priority.ts | 4.2 |
| node:buffer | 4 |
| npm:pg | 2.1 |
| npm:pg-protocol | 1.5 |
| src/lib/personal-records/pr-detection-worker.ts | 1.5 |
| src/lib/tz/zone-offset.ts | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 27.8 |
| src/lib/analytics/source-priority.ts | 17.7 |
| src/lib/validations/source-priority.ts | 13.5 |
| src/lib/measurements/consolidation-base.ts | 8.6 |
| src/lib/tz/format.ts | 7.1 |
| src/lib/measurements/consolidation-tz.ts | 7.1 |
| src/lib/tz/zone-offset.ts | 6 |
### at 75393 ms, 100 ms
| self time by module | ms |
| --- | ---: |
| (native) | 32.6 |
| npm:@prisma/client | 17.2 |
| src/lib/validations/source-priority.ts | 14.6 |
| npm:next | 7.5 |
| src/lib/personal-records/pr-detection-worker.ts | 6.1 |
| src/lib/tz/zone-offset.ts | 5.6 |
| npm:pg-protocol | 4.1 |
| npm:pg | 2.5 |
| src/lib/tz/format.ts | 2.3 |
| node:internal/async_local_storage/async_hooks | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 34.6 |
| src/lib/analytics/source-priority.ts | 24.3 |
| src/lib/validations/source-priority.ts | 16.1 |
| src/lib/tz/format.ts | 10.9 |
| src/lib/measurements/consolidation-tz.ts | 10.9 |
| src/lib/tz/zone-offset.ts | 8.6 |
| src/lib/measurements/consolidation-base.ts | 5.7 |
### at 78085 ms, 54 ms
| self time by module | ms |
| --- | ---: |
| src/lib/tz/zone-offset.ts | 26.6 |
| src/lib/validations/source-priority.ts | 13.5 |
| npm:@prisma/client | 4.2 |
| (native) | 4.1 |
| node:internal/async_local_storage/async_hooks | 1.5 |
| src/lib/tz/format.ts | 1.5 |
| src/lib/tz/intl-cache.ts | 1.5 |
| npm:pg | 1.1 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 44.7 |
| src/lib/tz/format.ts | 29.6 |
| src/lib/measurements/consolidation-tz.ts | 29.6 |
| src/lib/measurements/consolidation-base.ts | 29.6 |
| src/lib/tz/zone-offset.ts | 28.1 |
| src/lib/analytics/source-priority.ts | 15 |
| src/lib/validations/source-priority.ts | 13.5 |
| src/lib/tz/intl-cache.ts | 1.5 |
## Self time by module until the worker is ready
| module | ms |
| --- | ---: |
| (native) | 853 |
| npm:@prisma/client | 295.9 |
| node:internal/modules/cjs/loader | 248.7 |
| src/lib/validations/source-priority.ts | 210.3 |
| npm:next | 188.4 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 121.5 |
| src/lib/tz/zone-offset.ts | 83.2 |
| wasm://wasm/00d1cb12 | 81.4 |
| npm:pg | 78.5 |
| node:buffer | 59.1 |
| src/lib/tz/format.ts | 55 |
| npm:zod | 50.7 |
| src/lib/personal-records/pr-detection-worker.ts | 48 |
| npm:pg-protocol | 37 |
| node:internal/bootstrap/realm | 32 |
| src/lib/analytics/source-priority.ts | 31.7 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 27.7 |
| node:internal/modules/esm/utils | 26 |
| npm:luxon | 22.1 |
| node:internal/async_hooks | 21.7 |
| node:internal/async_local_storage/async_hooks | 21.2 |
| node:fs | 18.8 |
| npm:pg-boss | 18.3 |
| npm:pg-pool | 18.1 |
| npm:yaml | 15 |
## Heap snapshot after 300 s idle (live heap after a full GC)
159.5 MB in 1302659 nodes; module source strings over 50 KB: 39.8 MB.
- parsed locale catalogs: 2 objects, 1.6 MB retained
| node type | MB |
| --- | ---: |
| string | 67.5 |
| array | 26.6 |
| code | 19.2 |
| object | 15.9 |
| hidden | 9.2 |
| native | 8 |
| object shape | 5.9 |
| closure | 5.2 |
| concatenated string | 1.3 |
| number | 0.5 |
| sliced string | 0.2 |
| regexp | 0.2 |
| synthetic | 0 |
| symbol | 0 |
| bigint | 0 |
| constructor | MB | count |
| --- | ---: | ---: |
| array:(object elements) | 20.8 | 25651 |
| object:Object | 11.4 | 293893 |
| native:system / JSArrayBufferData | 7.5 | 170 |
| array:(object properties) | 3.3 | 6517 |
| array: | 2.5 | 10169 |
| closure: | 1.6 | 29121 |
| object:system / Context | 1.6 | 28886 |
| closure:native_bind | 1 | 20804 |
| object:Array | 0.6 | 20938 |
| object:Date1 | 0.5 | 5332 |
| object:a | 0.4 | 4438 |
| native:Node / histogram | 0.4 | 1 |
| object:d | 0.3 | 4425 |
| closure:get | 0.3 | 4732 |
| object:Set | 0.2 | 5670 |
| object:Module | 0.2 | 1963 |
| closure:set | 0.2 | 3428 |
| closure:n | 0.1 | 1572 |
| closure:i | 0.1 | 1578 |
| object:Promise | 0.1 | 1341 |
| largest retained subtrees | MB | retaining path |
| --- | ---: | --- |
| object:yr | 34.6 | [1] > [10] > 22 > extension > global > prisma > [1] > _engine |
| object:system / Context | 7.4 | Node / PrincipalRealm > base_object_list > Node / BaseObjectList > [25] > Node / ModuleWrap > native_to_javascript > ModuleWrap > 2 > [4] > context |
| object:Promise | 5.9 | Object > executor > e > #r > gr > transactions > Map > table > > 6 > closing |
| object:Module | 5.6 | [1] > [10] > 99 > process > getBuiltinModule > getBuiltinModule > context > system / Context > $Module > Module > _cache > |
| code:.next/server/chu | 5.6 | Module > _cache > Object > .next/server/chunks/_1kwfnjg._.js > Module > exports > Array > [1] > > shared > script |
| code:.next/server/chu | 5.6 | global > prisma > [1] > t > _engineConfig > Object > compilerWasm > Object > getRuntime > getRuntime > shared > script |
| object:Generator | 4.4 | [6] > principal_realm > Node / PrincipalRealm > base_object_list > Node / BaseObjectList > [26] > Node / ModuleWrap > native_to_javascript > ModuleWrap > 2 > [4 |
| string:AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/ | 4.4 | [1] > [21] > 60025 |
| object:Object | 3.4 | system / Context > $Module > Module > _cache > Object > .next/server/chunks/[turbopack]_runtime.js > Module > export |
| object:global | 2.6 | [1] > [10] > 22 > extension |
| largest strings | MB |
| --- | ---: |
| module.exports=[234494,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=> | 5.6 |
| module.exports=[17856,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=>i | 5.6 |
| module.exports=[17856,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=>i | 5.6 |
| const wasm = "AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/fwBgBX9/f39/AGAB | 4.4 |
| AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/fwBgBX9/f39/AGABfwF/YAZ/f39/f3 | 4.4 |
| lliADOAUngIEAACgKwAgBgAAoSsAIAcAAKErACAIAACiKwAgCQAAoysAIAoAAKQrACALAAClKwAgDAAApisAIA0AAK | 1.9 |
| (()=>{var e,t,r={"./dist/build/webpack/alias/react-dom-server.js"(e,t,r){"use strict";var | 1.3 |
| module.exports=[842865,(e,t,r)=>{let i=e.r(522734);t.exports=function(e){return e?/-----BE | 1.2 |
| module.exports=[842865,(e,t,r)=>{let i=e.r(522734);t.exports=function(e){return e?/-----BE | 1.2 |
| module.exports=[799092,(e,t,n)=>{"use strict";Object.defineProperty(n,"__esModule",{value: | 1.1 |
| // v1.4.34 web-freeze. Schema is locked additive-only until iOS launch // + v1.5.0 marker. | 1.1 |
| module.exports=[971743,(A,e,t)=>{"use strict";let r=A.r(57328),{addAbortListener:s}=A.r(68 | 1.1 |
| module.exports=[835717,(A,e,t)=>{"use strict";let r=A.r(57328),{addAbortListener:s}=A.r(68 | 1 |
| module.exports=[767189,e=>e.a(async(t,a)=>{try{var n=e.i(64105),r=e.i(563616),i=t([n]);[n] | 0.8 |
| module.exports=[594108,806176,674750,98746,t=>{"use strict";var i;let r;function n(t){retu | 0.7 |
+28
View File
@@ -0,0 +1,28 @@
# v1.45.0: boot and idle memory
`scripts/bench/boot-profile.ts`, production build, every role, heap ceiling
792 MB, against the seeded account, 300 s idle. `v1.45.0-before-boot` is the
v1.44 runtime, `v1.45.0-boot-after` the branch after the boot changes and
before the lazy match indexes, `v1.45.0-boot-final` the final tree.
| | before | final |
| --- | ---: | ---: |
| longest main-thread busy stretch, application code | 753 ms (worker graph, one block) | 336 ms (query compiler start, 324 ms before as well) |
| longest stretch loading the worker graph | 753 ms | 246 ms (one registrar) |
| live heap after full collections, idle | 162 to 172 MB | 142 to 153 MB |
| heap snapshot after 300 s idle | 172.3 MB | 159.5 MB |
| parsed locale catalogs in the heap | 32 objects, 14.1 MB | 2 objects, 1.6 MB |
| catalog chunk sources in the heap | 2 x 9.7 MB | none |
| module source strings over 50 KB | 44.2 MB | 39.8 MB |
| event-loop stalls logged (500 ms threshold) | 0 (monitor started late) | 0 (monitor first) |
Time to the first health answer and to a ready worker moved with the host's
load (1.6 s / 5.4 s before at load 9, 1.3 s / 3.2 s after at load 6) and is
not claimed as a result.
The rest of the 150 MB is the runtime itself: the query compiler (about
50 MB: its engine object, its WebAssembly source and chunk, twice), compiled
code (19 MB) and the remaining module sources. The used heap between full
collections stayed under 240 MB in both runs; the beta's 400 MB plateau is
garbage awaiting a collection under a larger limit plus the server caches of
real users, not a baseline the process cannot shed.
+22
View File
@@ -0,0 +1,22 @@
# v1.45.0: request fan-out
Same runs as `v1.45.0-read-path.md`.
| request | statements before | statements after | p50 ms before / after |
| --- | ---: | ---: | ---: |
| /api/auth/me | 19 | 14 | 9 / 8 |
| day view, cold | 49 | 48 | 77 / 68 |
- `/api/auth/me` read the AppSettings singleton six times (MFA policy,
Assistant switches, module availability, provider presence, AI usage
bounds, reasoning controls); one memoised read per request now serves all
six (`src/lib/settings/operator-row.ts`). On the small budget fixture it
went from 18 to 13 statements. The remaining reads are the session, the
grant, cycle profile, onboarding, consent, coach usage and five user reads
with different column sets.
- The day view's record sections run six at a time instead of about
twenty-five at once, so one day never holds more than a third of the pool.
The statement count is the same; what changes is the peak of connections.
- Keyset pagination for `/api/measurements` was not done: a cursor needs a
new request parameter and a response field, a contract change for the
native client.
+254
View File
@@ -0,0 +1,254 @@
{
"meta": {
"label": "v1.45.0-final",
"commit": "a57fa8335",
"date": "2026-10-22T06:52:09.539Z",
"node": "v22.23.2",
"postgres": "16.15",
"cpus": 14,
"loadAverage": [
6.8,
8.9,
14.5
],
"measurements": 1265561,
"iterations": 20,
"warmup": 3,
"heapMb": 792,
"idleStatementsPerSecond": 0,
"batch5k": {
"totalMs": 4448,
"totalStatements": 504
}
},
"results": [
{
"name": "/api/auth/me",
"mode": "cold",
"request": "GET /api/auth/me",
"n": 20,
"p50Ms": 8.3,
"p95Ms": 27.2,
"maxMs": 221,
"statementsMedian": 14,
"statementsMax": 14,
"failures": 0
},
{
"name": "/api/auth/me",
"mode": "warm",
"request": "GET /api/auth/me",
"n": 20,
"p50Ms": 7.9,
"p95Ms": 10.1,
"maxMs": 17.1,
"statementsMedian": 14,
"statementsMax": 17,
"failures": 0
},
{
"name": "dashboard summary",
"mode": "cold",
"request": "GET /api/dashboard/summary",
"n": 20,
"p50Ms": 86.6,
"p95Ms": 100.8,
"maxMs": 126.8,
"statementsMedian": 30,
"statementsMax": 31,
"failures": 0
},
{
"name": "dashboard summary",
"mode": "warm",
"request": "GET /api/dashboard/summary",
"n": 20,
"p50Ms": 9.2,
"p95Ms": 17.1,
"maxMs": 18.4,
"statementsMedian": 6,
"statementsMax": 6,
"failures": 0
},
{
"name": "dashboard snapshot",
"mode": "cold",
"request": "GET /api/dashboard/snapshot",
"n": 20,
"p50Ms": 545.3,
"p95Ms": 592.5,
"maxMs": 606.3,
"statementsMedian": 153,
"statementsMax": 156,
"failures": 0
},
{
"name": "dashboard snapshot",
"mode": "warm",
"request": "GET /api/dashboard/snapshot",
"n": 20,
"p50Ms": 8.1,
"p95Ms": 17.9,
"maxMs": 21.9,
"statementsMedian": 10,
"statementsMax": 10,
"failures": 0
},
{
"name": "series pulse 30 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=30",
"n": 20,
"p50Ms": 18.7,
"p95Ms": 27.2,
"maxMs": 36.1,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "series pulse 30 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=30",
"n": 20,
"p50Ms": 15.9,
"p95Ms": 21.8,
"maxMs": 26,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "series pulse 365 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=365",
"n": 20,
"p50Ms": 40.7,
"p95Ms": 55.6,
"maxMs": 56.2,
"statementsMedian": 7,
"statementsMax": 8,
"failures": 0
},
{
"name": "series pulse 365 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=365",
"n": 20,
"p50Ms": 38.3,
"p95Ms": 59.6,
"maxMs": 88.6,
"statementsMedian": 7,
"statementsMax": 7,
"failures": 0
},
{
"name": "series pulse 3650 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=3650",
"n": 20,
"p50Ms": 334.9,
"p95Ms": 348.5,
"maxMs": 351.2,
"statementsMedian": 7,
"statementsMax": 7,
"failures": 0
},
{
"name": "series pulse 3650 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=3650",
"n": 20,
"p50Ms": 328.1,
"p95Ms": 346,
"maxMs": 348.9,
"statementsMedian": 7,
"statementsMax": 8,
"failures": 0
},
{
"name": "day view",
"mode": "cold",
"request": "GET /api/day/2026-10-21",
"n": 20,
"p50Ms": 68.4,
"p95Ms": 102.6,
"maxMs": 104.7,
"statementsMedian": 48,
"statementsMax": 49,
"failures": 0
},
{
"name": "day view",
"mode": "warm",
"request": "GET /api/day/2026-10-21",
"n": 20,
"p50Ms": 46.4,
"p95Ms": 66.1,
"maxMs": 72.6,
"statementsMedian": 47,
"statementsMax": 47,
"failures": 0
},
{
"name": "insights health status",
"mode": "cold",
"request": "GET /api/insights/health-status",
"n": 20,
"p50Ms": 156,
"p95Ms": 190,
"maxMs": 197.7,
"statementsMedian": 67,
"statementsMax": 67,
"failures": 0
},
{
"name": "insights health status",
"mode": "warm",
"request": "GET /api/insights/health-status",
"n": 20,
"p50Ms": 173.6,
"p95Ms": 1163.4,
"maxMs": 1419.4,
"statementsMedian": 67,
"statementsMax": 67,
"failures": 0
},
{
"name": "insights correlations",
"mode": "cold",
"request": "GET /api/insights/correlations",
"n": 20,
"p50Ms": 106.1,
"p95Ms": 229.1,
"maxMs": 383.3,
"statementsMedian": 48,
"statementsMax": 49,
"failures": 0
},
{
"name": "insights correlations",
"mode": "warm",
"request": "GET /api/insights/correlations",
"n": 20,
"p50Ms": 18.7,
"p95Ms": 626.6,
"maxMs": 1136.5,
"statementsMedian": 7,
"statementsMax": 7,
"failures": 0
},
{
"name": "batch ingest 5k (per 500)",
"mode": "write",
"request": "POST /api/measurements/batch ×10",
"n": 10,
"p50Ms": 185.7,
"p95Ms": 1269.2,
"maxMs": 1269.2,
"statementsMedian": 50,
"statementsMax": 53,
"failures": 0
}
]
}
+25
View File
@@ -0,0 +1,25 @@
# Benchmark v1.45.0-final
Commit a57fa8335 with the working tree of the final commits, 2026-10-22T06:52:09.539Z. 1265561 measurement rows on the bench account, 20 measured requests per row after 3 warm-up requests, one at a time. Server: production build, web process, heap ceiling 792 MB. Postgres 16.15, 14 cores, load average 6.8,8.9,14.5 at the end. Idle background: 0 statements/s.
| Request | Mode | n | p50 ms | p95 ms | max ms | statements (median) | statements (max) |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
| /api/auth/me | cold | 20 | 8.3 | 27.2 | 221 | 14 | 14 |
| /api/auth/me | warm | 20 | 7.9 | 10.1 | 17.1 | 14 | 17 |
| dashboard summary | cold | 20 | 86.6 | 100.8 | 126.8 | 30 | 31 |
| dashboard summary | warm | 20 | 9.2 | 17.1 | 18.4 | 6 | 6 |
| dashboard snapshot | cold | 20 | 545.3 | 592.5 | 606.3 | 153 | 156 |
| dashboard snapshot | warm | 20 | 8.1 | 17.9 | 21.9 | 10 | 10 |
| series pulse 30 d | cold | 20 | 18.7 | 27.2 | 36.1 | 8 | 8 |
| series pulse 30 d | warm | 20 | 15.9 | 21.8 | 26 | 8 | 8 |
| series pulse 365 d | cold | 20 | 40.7 | 55.6 | 56.2 | 7 | 8 |
| series pulse 365 d | warm | 20 | 38.3 | 59.6 | 88.6 | 7 | 7 |
| series pulse 3650 d | cold | 20 | 334.9 | 348.5 | 351.2 | 7 | 7 |
| series pulse 3650 d | warm | 20 | 328.1 | 346 | 348.9 | 7 | 8 |
| day view | cold | 20 | 68.4 | 102.6 | 104.7 | 48 | 49 |
| day view | warm | 20 | 46.4 | 66.1 | 72.6 | 47 | 47 |
| insights health status | cold | 20 | 156 | 190 | 197.7 | 67 | 67 |
| insights health status | warm | 20 | 173.6 | 1163.4 | 1419.4 | 67 | 67 |
| insights correlations | cold | 20 | 106.1 | 229.1 | 383.3 | 48 | 49 |
| insights correlations | warm | 20 | 18.7 | 626.6 | 1136.5 | 7 | 7 |
| batch ingest 5k (per 500) | write | 10 | 185.7 | 1269.2 | 1269.2 | 50 | 53 |
@@ -0,0 +1,45 @@
# v1.45.0: read path
Request benchmark on the seeded account (1,265,561 rows), production build,
web process, heap ceiling 792 MB, 20 requests per row, one at a time.
`v1.45.0-before` is the v1.44 runtime (1915f9152), `v1.45.0-final` this
branch; both on the same machine within the hour, load average 7 to 30
(other work on the host), so latency compares within a factor of noise and
statements exactly. Cold means every request follows a one-row manual sync.
| request | before p50 / p95 ms | after p50 / p95 ms | statements before / after |
| --- | ---: | ---: | ---: |
| dashboard snapshot, cold | 1482 / 1526 | 545 / 593 | 158 / 153 |
| dashboard summary, cold | 170 / 221 | 87 / 101 | 31 / 30 |
| series pulse 3650 d, cold | 1627 / 1665 | 335 / 349 | 8 / 7 |
| series pulse 3650 d, warm | 2091 / 2435 | 328 / 346 | 8 / 7 |
| series pulse 365 d, cold | 184 / 193 | 41 / 56 | 8 / 7 |
| series pulse 30 d, cold | 24 / 31 | 19 / 27 | 8 / 8 |
| insights health status, cold | 245 / 401 | 156 / 190 | 68 / 67 |
What moved them:
- **JIT off for app sessions.** Postgres compiled every fold whose estimated
cost passed `jit_above_cost`; on the ten-year pulse pick that was 100 to
140 ms of a 220 ms statement (`EXPLAIN ANALYZE`: 395 ms with JIT, 220 ms
without).
- **The canonical-source pick groups first.** `DISTINCT ON` now sorts the
distinct `(type, day, source)` triples instead of every raw reading (the
ten-year pulse pick sorted 260,000 rows and spilled to disk); 220 ms to
175 ms on its own. Parity: `tests/integration/canonical-source-pick-parity.test.ts`
runs the old statement beside the new one over five reader shapes and two
ladders on a fixture with overlapping and off-ladder sources, tombstones,
empty days and readings around midnight; identical rows.
- **The dense series reads its rows once.** Buckets and strip figures come
from one statement over a materialised window instead of two statements
that each picked the canonical source and scanned the readings. Parity:
`tests/integration/dense-series-read-parity.test.ts`, the two old
statements as reference, pulse (two ranked sources), glucose and oxygen
saturation, day and hour grains, UTC, Europe/Berlin, Asia/Kolkata and
America/New_York; identical buckets and figures (relative 1e-9).
- **Closed blocks survive a sync.** The slice's pre-fold remainder (every
reading older than five years, the snapshot's largest statement at 357 ms)
and the day view's past history blocks are cached under a key that names
the instant their readings end before; a sync of newer readings no longer
drops them (`src/lib/cache/closed-block.ts`). The values are the same
statement's; only how long they are kept changed.
@@ -0,0 +1,28 @@
# v1.45.0: workout duplicate window
A correctness change, not a speed one; the request benchmark does not read
workouts, so there is no latency row for it.
Before: two copies of one session were collapsed only when both starts fell
in the same fixed five-minute clock slot (`floor(start / 5 min)`). A pair 30 s
apart that straddled a slot boundary stayed two workouts in the list, the
batch write, the calendar, strain and the Coach.
After: per user and sport, sorted by start, a workout joins the open cluster
while it starts less than five minutes after the cluster's first start.
Proof (`src/lib/measurements/__tests__/pick-canonical-workout-rows.property.test.ts`,
against the previous picker kept as `pick-canonical-workout-rows.legacy.ts`):
| property | inputs |
| --- | --- |
| same rows as the fixed slots wherever no pair within five minutes straddles a slot boundary, three ladders | 4,000 generated inputs, over 1,000 comparable |
| same strain impulses as before on those inputs | 2,000 generated inputs, over 500 comparable |
| two sources' copies collapse at any offset under five minutes (the old picker missed a share) | 3,000 random offsets |
| five minutes or more apart never collapse | 2,000 |
| a uniform shift of every start keeps the picked set | 1,500 |
| a row of workouts four minutes apart does not chain | fixed case |
One existing case changed its expectation: three runs at 08:04, 08:08 and
08:12 used to stay three because they sat in three slots; 08:08 is now the
08:04 run from a lower-ranked source (four minutes apart), and 08:12 stays.
@@ -0,0 +1,26 @@
# v1.45.0: write path
Same runs as `v1.45.0-read-path.md`.
| request | before p50 ms | after p50 ms | statements before / after |
| --- | ---: | ---: | ---: |
| batch ingest, 500 fresh rows per request | 166 | 186 | 53 / 50 |
Fresh rows were already set-based (v1.44); the benchmark's ingest is all
fresh rows, so it shows no gain, as expected. The changes are for re-posts:
- **Unchanged `stats:` re-posts** write nothing and start nothing. One read
over the batch's `stats:` ids replaces a reconciler transaction (about ten
round trips) per row, and an all-unchanged batch no longer refolds, enqueues
rollups or PR detection, or marks caches stale. The per-entry status stays
`updated` and the summary's `updated` count is unchanged.
`tests/integration/routes/measurements-batch-unchanged-stats.test.ts`: four
unchanged re-posts take at most 12 statements in total (before: about 40)
with zero rollup, invalidation and PR calls; a changed value, a changed
device or a tombstoned row still goes through the reconciler.
- **Apple Health re-import** skips the UPDATE of a row stored with exactly the
values it would write; one read per chunk instead of one UPDATE per row.
`tests/integration/apple-health-reimport-unchanged.test.ts`: a re-import of
20 stored rows reports the same per-type counts and sends no UPDATE.
- **Narrower invalidation.** The batch route tells the cache how far back it
wrote (`touchedFrom`), so closed blocks of older readings stay.
+478
View File
@@ -0,0 +1,478 @@
/**
* Request benchmark against a seeded account: latency (p50, p95, max) and
* SQL statements per request for the hot read and write paths.
*
* DATABASE_URL=postgresql://… API_TOKEN_HMAC_KEY=… ENCRYPTION_KEYS=… \
* ENCRYPTION_ACTIVE_KEY_ID=… pnpm dlx tsx scripts/bench/run.ts \
* [--user bench-large] [--iterations 20] [--warmup 3] \
* [--base-url http://127.0.0.1:3100] [--out scripts/bench/results/<label>]
*
* See docs/contributing/benchmarks.md for the whole setup (a throwaway
* Postgres with pg_stat_statements, migrations, the seed, a production
* build). Never point it at a database that holds real data: it mints a
* token for the bench user and writes measurements under it.
*
* Without `--base-url` the script starts the production build itself
* (`next start` on `.next`) as a web-only process under the production heap
* ceiling, and stops it at the end.
*
* Statements are counted with pg_stat_statements: the sum of `calls` over
* the database, read before and after each request, once no statement is
* in flight and the count has stopped moving (so fire-and-forget writes a
* request starts are counted with it). The bench's own reads of the two
* views are excluded. Requests run one at a time, so nothing else is in the
* delta except the server's idle background, which is measured first and
* reported. Before the measurement the build boots once with every role, so
* the worker creates its queues as it does in production; whatever that
* boot scheduled is dropped.
*
* Each read runs twice: cold, with the user's caches evicted before every
* request by a one-row manual-sync ingest (excluded from the numbers), so
* the request rebuilds what it serves; and warm, back to back, which is
* what a cache hit costs. The batch ingest posts 5,000 fresh rows as ten
* requests of 500, the route's per-request limit, and removes them again.
*
* Output: `<out>.json` and `<out>.md`. Exit 2 on any non-2xx response.
*/
import { execSync, spawn, type ChildProcess } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { cpus, loadavg } from "node:os";
import { dirname, join } from "node:path";
import { Pool } from "pg";
import { issueApiToken } from "@/lib/auth/issue-token";
import { prisma } from "@/lib/db";
function arg(name: string, fallback: string): string {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1]! : fallback;
}
const USER_ID = arg("user", "bench-large");
const ITERATIONS = Number(arg("iterations", "20"));
const WARMUP = Number(arg("warmup", "3"));
const PORT = Number(arg("port", "3100"));
const BASE_URL = arg("base-url", "");
const OUT = arg("out", join("scripts", "bench", "results", "latest"));
const HEAP_MB = Number(arg("heap-mb", "792"));
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 1 });
async function statementCount(): Promise<number> {
const { rows } = await pool.query<{ n: string | null }>(
`SELECT sum(calls)::bigint AS n FROM pg_stat_statements
WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
AND query NOT ILIKE '%pg_stat_statements%'
AND query NOT ILIKE '%pg_stat_activity%'`,
);
return Number(rows[0]?.n ?? 0);
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/**
* Wait until the server has no statement in flight and the counter holds
* still for 150 ms (or 10 s pass), then read it. pg_stat_statements counts a
* statement when it ends, so a long fold in a background refresh would look
* settled by the counter alone; pg_stat_activity says it is still running.
*/
async function settledCount(): Promise<number> {
let last = -1;
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
const { rows } = await pool.query<{ active: string }>(
`SELECT count(*)::int AS active FROM pg_stat_activity
WHERE datname = current_database() AND pid <> pg_backend_pid()
AND state NOT IN ('idle')`,
);
const now = await statementCount();
if (Number(rows[0]?.active ?? 0) === 0 && now === last) return now;
last = now;
await sleep(150);
}
return last;
}
function percentile(sorted: number[], p: number): number {
if (sorted.length === 0) return 0;
const i = Math.ceil((p / 100) * sorted.length) - 1;
return sorted[Math.min(sorted.length - 1, Math.max(0, i))]!;
}
interface Sample {
ms: number;
statements: number;
status: number;
}
interface ScenarioResult {
name: string;
mode: "cold" | "warm" | "write";
request: string;
n: number;
p50Ms: number;
p95Ms: number;
maxMs: number;
statementsMedian: number;
statementsMax: number;
failures: number;
}
let token = "";
const failures: string[] = [];
async function call(
method: string,
path: string,
body?: unknown,
): Promise<Sample> {
const before = await settledCount();
const started = performance.now();
const res = await fetch(`${base()}${path}`, {
method,
headers: {
authorization: `Bearer ${token}`,
...(body ? { "content-type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
await res.arrayBuffer();
const ms = performance.now() - started;
const after = await settledCount();
if (!res.ok) failures.push(`${method} ${path}: ${res.status}`);
return { ms, statements: after - before, status: res.status };
}
function summarise(
name: string,
mode: ScenarioResult["mode"],
request: string,
samples: Sample[],
): ScenarioResult {
const ms = samples.map((s) => s.ms).sort((a, b) => a - b);
const st = samples.map((s) => s.statements).sort((a, b) => a - b);
const r = (n: number) => Math.round(n * 10) / 10;
return {
name,
mode,
request,
n: samples.length,
p50Ms: r(percentile(ms, 50)),
p95Ms: r(percentile(ms, 95)),
maxMs: r(ms[ms.length - 1] ?? 0),
statementsMedian: percentile(st, 50),
statementsMax: st[st.length - 1] ?? 0,
failures: samples.filter((s) => s.status >= 300).length,
};
}
/**
* The per-user rate limits would refuse a benchmark long before a person
* meets them (one run sends several hundred requests in minutes), so the
* bench user's buckets are cleared before each scenario. The limiter's own
* cost stays in every count.
*/
async function resetRateLimits(): Promise<void> {
await pool.query(`DELETE FROM rate_limits WHERE key LIKE '%:' || $1`, [
USER_ID,
]);
}
let bustCounter = 0;
/**
* Evict the user's caches with a real change, outside the numbers: a one-row
* ingest marked as a manual sync, which hard-evicts instead of marking
* stale, so the next read is a miss and rebuilds in the request.
*/
async function bust(anchorDay: string): Promise<void> {
bustCounter += 1;
const res = await fetch(`${base()}/api/measurements/batch`, {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
"content-type": "application/json",
},
body: JSON.stringify({
syncTrigger: "manual",
entries: [
{
hkIdentifier: "HKQuantityTypeIdentifierFlightsClimbed",
value: 10 + (bustCounter % 2),
unit: "count",
startDate: `${anchorDay}T08:00:00.000Z`,
endDate: `${anchorDay}T18:00:00.000Z`,
externalId: `stats:HKQuantityTypeIdentifierFlightsClimbed:${anchorDay}`,
},
],
}),
});
const body = (await res.json()) as {
data?: { inserted?: number; updated?: number };
};
// A bust that wrote nothing evicted nothing, and every "cold" number
// after it would be a cache hit.
if ((body.data?.inserted ?? 0) + (body.data?.updated ?? 0) !== 1) {
throw new Error(`cache bust wrote no row: ${JSON.stringify(body)}`);
}
}
function base(): string {
return BASE_URL || `http://127.0.0.1:${PORT}`;
}
async function waitForHealth(
timeoutMs: number,
server: ChildProcess | null,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (server && server.exitCode !== null) {
throw new Error(`the server exited with code ${server.exitCode}`);
}
try {
const res = await fetch(`${base()}/api/health`);
if (res.ok) return;
} catch {
// not listening yet
}
await sleep(500);
}
throw new Error(`server at ${base()} did not answer /api/health`);
}
function startServer(role: "web" | "all"): ChildProcess {
// `next start` rather than the standalone server: the standalone tree
// lacks the externalised runtime packages the image installs separately
// (see the Dockerfile), while the checkout has them. It serves the same
// build output.
const child = spawn(
process.execPath,
[
"node_modules/next/dist/bin/next",
"start",
"-p",
String(PORT),
"-H",
"127.0.0.1",
],
{
env: {
...process.env,
NODE_ENV: "production",
PORT: String(PORT),
HOSTNAME: "127.0.0.1",
HEALTHLOG_PROCESS_TYPE: role,
SESSION_COOKIE_SECURE: "false",
NODE_OPTIONS: `--max-old-space-size=${HEAP_MB}`,
},
stdio: ["ignore", "ignore", "inherit"],
},
);
return child;
}
async function stopServer(child: ChildProcess): Promise<void> {
if (child.exitCode !== null) return;
const exited = new Promise((resolve) => child.once("exit", resolve));
child.kill("SIGTERM");
await Promise.race([exited, sleep(20_000)]);
if (child.exitCode === null) child.kill("SIGKILL");
}
function freshBatch(run: string, chunk: number): unknown[] {
// 500 heart-rate readings a minute apart, on a day inside the seeded
// window, with ids no earlier run used.
const day = Date.UTC(2026, 0, 1) + chunk * 86_400_000;
return Array.from({ length: 500 }, (_, i) => {
const at = new Date(day + i * 60_000).toISOString();
return {
hkIdentifier: "HKQuantityTypeIdentifierHeartRate",
value: 55 + (i % 40),
unit: "count/min",
startDate: at,
endDate: at,
externalId: `bench-${run}-${chunk}-${i}`,
};
});
}
function markdown(meta: Record<string, unknown>, rows: ScenarioResult[]) {
const lines = [
`# Benchmark ${String(meta.label)}`,
"",
`Commit ${String(meta.commit)}, ${String(meta.date)}. ${String(meta.measurements)} measurement rows on the bench account, ${ITERATIONS} measured requests per row after ${WARMUP} warm-up requests, one at a time. Server: production build, web process, heap ceiling ${HEAP_MB} MB. Postgres ${String(meta.postgres)}, ${String(meta.cpus)} cores, load average ${String(meta.loadAverage)} at the end. Idle background: ${String(meta.idleStatementsPerSecond)} statements/s.`,
"",
"| Request | Mode | n | p50 ms | p95 ms | max ms | statements (median) | statements (max) |",
"| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |",
...rows.map(
(r) =>
`| ${r.name} | ${r.mode} | ${r.n} | ${r.p50Ms} | ${r.p95Ms} | ${r.maxMs} | ${r.statementsMedian} | ${r.statementsMax} |`,
),
"",
];
return lines.join("\n");
}
async function main(): Promise<void> {
if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required");
await pool.query("CREATE EXTENSION IF NOT EXISTS pg_stat_statements");
await statementCount(); // fails here when the library is not preloaded
const user = await prisma.user.findUnique({
where: { id: USER_ID },
select: { id: true },
});
if (!user) throw new Error(`no user ${USER_ID}; run the seed first`);
const measurements = await prisma.measurement.count({
where: { userId: USER_ID },
});
token = (
await issueApiToken({
userId: USER_ID,
name: "benchmark",
permissions: ["*"],
expiresInDays: 1,
})
).token;
// A web-only process only sends jobs; the queues are created by the
// worker role. Boot the full process once so every queue exists (as it
// does in production), then run the measurement against a web-only
// process, so the worker's polling is not in every delta.
if (!BASE_URL) {
const full = startServer("all");
await waitForHealth(180_000, full);
await stopServer(full);
// Drop whatever that boot scheduled: nothing in the web-only process
// runs jobs, and a fold left queued would change the fixture on the
// next full boot.
await pool.query("TRUNCATE pgboss.job");
}
const server = BASE_URL ? null : startServer("web");
try {
await waitForHealth(180_000, server);
const idleBefore = await settledCount();
await sleep(5_000);
const idleStatementsPerSecond =
Math.round(((await statementCount()) - idleBefore) / 0.5) / 10;
const yesterday = new Date(Date.now() - 86_400_000)
.toISOString()
.slice(0, 10);
const reads: Array<[string, string]> = [
["/api/auth/me", "/api/auth/me"],
["dashboard summary", "/api/dashboard/summary"],
["dashboard snapshot", "/api/dashboard/snapshot"],
["series pulse 30 d", "/api/measurements/series?kind=pulse&days=30"],
["series pulse 365 d", "/api/measurements/series?kind=pulse&days=365"],
["series pulse 3650 d", "/api/measurements/series?kind=pulse&days=3650"],
["day view", `/api/day/${yesterday}`],
["insights health status", "/api/insights/health-status"],
["insights correlations", "/api/insights/correlations"],
];
const results: ScenarioResult[] = [];
for (const [name, path] of reads) {
await resetRateLimits();
for (let i = 0; i < WARMUP; i++) await call("GET", path);
const cold: Sample[] = [];
for (let i = 0; i < ITERATIONS; i++) {
await bust(yesterday);
cold.push(await call("GET", path));
}
results.push(summarise(name, "cold", `GET ${path}`, cold));
await resetRateLimits();
const warm: Sample[] = [];
for (let i = 0; i < ITERATIONS; i++) warm.push(await call("GET", path));
results.push(summarise(name, "warm", `GET ${path}`, warm));
console.log(
`${name.padEnd(26)} cold p95 ${results.at(-2)!.p95Ms} ms / ${results.at(-2)!.statementsMedian} st, warm p95 ${results.at(-1)!.p95Ms} ms / ${results.at(-1)!.statementsMedian} st`,
);
}
// Rows an earlier run wrote would turn this run's inserts into
// reconciles of the same natural key, so they go first, and again at
// the end, leaving the seeded fixture as it was.
const clean = () =>
pool.query(
`DELETE FROM measurements WHERE user_id = $1 AND external_id LIKE 'bench-%'`,
[USER_ID],
);
await clean();
await resetRateLimits();
const run = Date.now().toString(36);
const writes: Sample[] = [];
for (let chunk = 0; chunk < 10; chunk++) {
writes.push(
await call("POST", "/api/measurements/batch", {
entries: freshBatch(run, chunk),
}),
);
}
const batch = summarise(
"batch ingest 5k (per 500)",
"write",
"POST /api/measurements/batch ×10",
writes,
);
results.push(batch);
await clean();
const totalMs = writes.reduce((a, s) => a + s.ms, 0);
const totalStatements = writes.reduce((a, s) => a + s.statements, 0);
console.log(
`batch ingest 5k total ${Math.round(totalMs)} ms / ${totalStatements} st`,
);
let commit = "unknown";
try {
commit = execSync("git rev-parse --short HEAD").toString().trim();
} catch {
// not a checkout
}
const meta = {
label: OUT.split("/").pop(),
commit,
date: new Date().toISOString(),
node: process.version,
postgres: (
await pool.query<{ v: string }>(
"SELECT current_setting('server_version') AS v",
)
).rows[0]?.v,
cpus: cpus().length,
loadAverage: loadavg().map((n) => Math.round(n * 10) / 10),
measurements,
iterations: ITERATIONS,
warmup: WARMUP,
heapMb: HEAP_MB,
idleStatementsPerSecond,
batch5k: { totalMs: Math.round(totalMs), totalStatements },
};
mkdirSync(dirname(OUT), { recursive: true });
writeFileSync(
`${OUT}.json`,
`${JSON.stringify({ meta, results }, null, 2)}\n`,
);
writeFileSync(`${OUT}.md`, markdown(meta, results));
console.log(`wrote ${OUT}.json and ${OUT}.md`);
} finally {
if (server) await stopServer(server);
}
if (failures.length > 0) {
console.error(`non-2xx responses:\n ${failures.join("\n ")}`);
process.exitCode = 2;
}
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
await pool.end();
});
+659
View File
@@ -0,0 +1,659 @@
/**
* Seed one synthetic account the size of a heavy real one, for the benchmark
* harness (`scripts/bench/run.ts`) and for reproducing the maintenance jobs:
* about 1.26 million measurement rows over ten years, plus workouts, mood
* entries and medications with their intake history.
*
* DATABASE_URL=postgresql://… pnpm dlx tsx scripts/bench/seed-large-account.ts \
* [--user bench-large] [--years 10] [--anchor 2026-10-22] [--reset]
* [--no-rollups]
*
* Never point this at a database that holds real data: it inserts rows under
* the given user id and, with `--reset`, deletes that user (and, through the
* cascades, everything it owns) first.
*
* Deterministic: every value and timestamp is a function of the row index and
* the anchor day (`--anchor`, UTC midnight; default today), and ids are
* derived from the user id, the series and the index. Two runs with the same
* arguments write the same rows.
*
* The shape follows what the integrations write, with overlapping sources so
* the canonical-source pick has work to do:
*
* - Apple Health dense spot rows (heart rate about every ten minutes, HRV,
* SpO2, respiratory rate, gait metrics, audio exposure) keyed by a hashed
* sample id;
* - Withings morning and evening blood pressure with its pulse, and daily
* weight, body fat and muscle mass; a weekly manual weight beside it;
* - Oura nightly resting heart rate and RMSSD for the last three years,
* beside Apple Health's resting heart rate;
* - Nightscout CGM glucose every five minutes for the last 90 days;
* - one `stats:` daily row per cumulative type and day (the export import)
* and per-sample cumulative rows with opaque ids for the last 90 days
* (the iOS sync);
* - sleep-stage segments every night;
* - about 3,000 workouts, a mood entry a day for three years, and three
* daily medications with their intake events for three years.
*
* Everything goes in through set-based `INSERT … SELECT … generate_series`,
* so a million rows take seconds rather than a million round trips. The
* rollup tier is then folded with `recomputeUserRollups`, the same path the
* boot backfill takes for an imported account, so reads see the state a real
* account is in. `ANALYZE` runs at the end: a planner reading pre-seed
* statistics picks plans no production table of this size would get.
*/
import { prisma } from "@/lib/db";
import {
ALL_GRANULARITIES,
recomputeUserRollups,
} from "@/lib/rollups/measurement-rollups";
function arg(name: string, fallback: string): string {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1]! : fallback;
}
const USER_ID = arg("user", "bench-large");
const YEARS = Number(arg("years", "10"));
const ANCHOR = arg("anchor", new Date().toISOString().slice(0, 10));
const RESET = process.argv.includes("--reset");
const ROLLUPS = !process.argv.includes("--no-rollups");
const ZONE = "Europe/Berlin";
if (!/^\d{4}-\d{2}-\d{2}$/.test(ANCHOR)) {
throw new Error(`--anchor must be YYYY-MM-DD, got ${ANCHOR}`);
}
/**
* A spot series: rows evenly spaced over the last `days` days before the
* anchor. `perDay` above one spaces rows through the day; `everyDays` above
* one writes one row every that many days at `offsetSec` past midnight UTC.
*/
interface SpotSeries {
type: string;
unit: string;
source: string;
base: number;
spread: number;
days: number;
perDay?: number;
everyDays?: number;
offsetSec?: number;
/** externalId prefix; `stats:` rows are generated separately. */
idPrefix: string;
}
async function insertSpot(s: SpotSeries): Promise<number> {
const step = s.everyDays
? s.everyDays * 86_400
: Math.floor(86_400 / (s.perDay ?? 1));
const count = Math.floor((s.days * 86_400) / step);
// Jitter stays below the step so every measured_at is distinct per type
// and source (the (user, type, measured_at, source, sleep_stage) unique
// index). Series that share a step and offset (BP systolic, diastolic and
// the Withings pulse) share their timestamps, as a cuff reading does.
const jitter = Math.max(1, Math.min(59, step - 1));
return prisma.$executeRawUnsafe(
`
INSERT INTO measurements
(id, user_id, type, value, unit, source, measured_at, external_id, created_at, updated_at)
SELECT
'seed-' || $1 || '-' || $2 || '-' || $10 || '-' || g,
$1,
$2::measurement_type,
round(($3 + $4 * sin(g / 37.0) + ($4 / 2) * ((g * 7919) % 100) / 100.0)::numeric, 2),
$5,
$10::measurement_source,
$11::date::timestamp - make_interval(secs => $6 * $7::bigint)
+ make_interval(secs => g * $6 + $12 + (g * 31) % $8),
$9 || md5($2 || $10 || g::text),
now(), now()
FROM generate_series(0, $7::bigint - 1) AS g
`,
USER_ID,
s.type,
s.base,
s.spread,
s.unit,
step,
count,
jitter,
s.idPrefix,
s.source,
ANCHOR,
s.offsetSec ?? 0,
);
}
function log(label: string, n: number): void {
console.log(`${label.padEnd(40)} ${String(n).padStart(9)}`);
}
async function seedMeasurements(days: number): Promise<number> {
const recent = Math.min(days, 90);
const threeYears = Math.min(days, 3 * 365);
const spot: SpotSeries[] = [
// Apple Health, the dense tier.
{
type: "PULSE",
unit: "bpm",
source: "APPLE_HEALTH",
perDay: 147,
base: 62,
spread: 25,
days,
idPrefix: "hk-",
},
{
type: "HEART_RATE_VARIABILITY",
unit: "ms",
source: "APPLE_HEALTH",
perDay: 12,
base: 45,
spread: 20,
days,
idPrefix: "hk-",
},
{
type: "OXYGEN_SATURATION",
unit: "%",
source: "APPLE_HEALTH",
perDay: 24,
base: 96,
spread: 2,
days,
idPrefix: "hk-",
},
{
type: "RESPIRATORY_RATE",
unit: "breaths/min",
source: "APPLE_HEALTH",
perDay: 24,
base: 14,
spread: 3,
days,
idPrefix: "hk-",
},
{
type: "WALKING_SPEED",
unit: "m/s",
source: "APPLE_HEALTH",
perDay: 12,
base: 1.3,
spread: 0.2,
days,
idPrefix: "hk-",
},
{
type: "WALKING_STEP_LENGTH",
unit: "m",
source: "APPLE_HEALTH",
perDay: 12,
base: 0.72,
spread: 0.06,
days,
idPrefix: "hk-",
},
{
type: "WALKING_ASYMMETRY",
unit: "%",
source: "APPLE_HEALTH",
perDay: 12,
base: 3,
spread: 2,
days,
idPrefix: "hk-",
},
{
type: "WALKING_DOUBLE_SUPPORT",
unit: "%",
source: "APPLE_HEALTH",
perDay: 12,
base: 28,
spread: 3,
days,
idPrefix: "hk-",
},
{
type: "AUDIO_EXPOSURE_ENV",
unit: "dBA",
source: "APPLE_HEALTH",
perDay: 24,
base: 62,
spread: 12,
days,
idPrefix: "hk-",
},
{
type: "RESTING_HEART_RATE",
unit: "bpm",
source: "APPLE_HEALTH",
perDay: 1,
offsetSec: 6 * 3600,
base: 56,
spread: 4,
days,
idPrefix: "hk-",
},
{
type: "TIME_IN_DAYLIGHT",
unit: "minutes",
source: "APPLE_HEALTH",
perDay: 1,
offsetSec: 18 * 3600,
base: 60,
spread: 40,
days,
idPrefix: "hk-",
},
// The iOS sync's per-sample cumulative rows: dense, recent, opaque ids.
{
type: "ACTIVITY_STEPS",
unit: "steps",
source: "APPLE_HEALTH",
perDay: 100,
base: 90,
spread: 60,
days: recent,
idPrefix: "uuid-",
},
{
type: "ACTIVE_ENERGY_BURNED",
unit: "kcal",
source: "APPLE_HEALTH",
perDay: 400,
base: 1.2,
spread: 0.9,
days: recent,
idPrefix: "uuid-",
},
{
type: "WALKING_RUNNING_DISTANCE",
unit: "m",
source: "APPLE_HEALTH",
perDay: 100,
base: 70,
spread: 50,
days: recent,
idPrefix: "uuid-",
},
// Withings: a cuff reading morning and evening, and the scale.
{
type: "BLOOD_PRESSURE_SYS",
unit: "mmHg",
source: "WITHINGS",
perDay: 2,
offsetSec: 6 * 3600,
base: 126,
spread: 10,
days,
idPrefix: "wi-",
},
{
type: "BLOOD_PRESSURE_DIA",
unit: "mmHg",
source: "WITHINGS",
perDay: 2,
offsetSec: 6 * 3600,
base: 81,
spread: 7,
days,
idPrefix: "wi-",
},
{
type: "PULSE",
unit: "bpm",
source: "WITHINGS",
perDay: 2,
offsetSec: 6 * 3600,
base: 66,
spread: 8,
days,
idPrefix: "wi-",
},
{
type: "WEIGHT",
unit: "kg",
source: "WITHINGS",
perDay: 1,
offsetSec: 5 * 3600,
base: 82,
spread: 1.5,
days,
idPrefix: "wi-",
},
{
type: "BODY_FAT",
unit: "%",
source: "WITHINGS",
perDay: 1,
offsetSec: 5 * 3600,
base: 22,
spread: 2,
days,
idPrefix: "wi-",
},
{
type: "MUSCLE_MASS",
unit: "kg",
source: "WITHINGS",
perDay: 1,
offsetSec: 5 * 3600,
base: 61,
spread: 1,
days,
idPrefix: "wi-",
},
// A weekly manual weight beside the scale.
{
type: "WEIGHT",
unit: "kg",
source: "MANUAL",
everyDays: 7,
offsetSec: 19 * 3600,
base: 82.4,
spread: 1.5,
days,
idPrefix: "man-",
},
// Oura, nightly, the last three years.
{
type: "RESTING_HEART_RATE",
unit: "bpm",
source: "OURA",
perDay: 1,
offsetSec: 4 * 3600,
base: 54,
spread: 4,
days: threeYears,
idPrefix: "oura-",
},
{
type: "HRV_RMSSD",
unit: "ms",
source: "OURA",
perDay: 1,
offsetSec: 4 * 3600,
base: 48,
spread: 15,
days: threeYears,
idPrefix: "oura-",
},
// Nightscout CGM, every five minutes, the last 90 days.
{
type: "BLOOD_GLUCOSE",
unit: "mg/dL",
source: "NIGHTSCOUT",
perDay: 288,
base: 105,
spread: 30,
days: recent,
idPrefix: "ns-",
},
];
let total = 0;
for (const s of spot) {
const n = await insertSpot(s);
total += n;
log(`${s.type} (${s.source})`, n);
}
// One export-estimate `stats:` row per cumulative type and local day, at
// local noon — what `flushCumulativeBuckets` in the export import writes —
// for every day older than the per-sample window.
for (const [type, hk, unit, base] of [
["ACTIVITY_STEPS", "HKQuantityTypeIdentifierStepCount", "steps", 8000],
[
"ACTIVE_ENERGY_BURNED",
"HKQuantityTypeIdentifierActiveEnergyBurned",
"kcal",
550,
],
[
"WALKING_RUNNING_DISTANCE",
"HKQuantityTypeIdentifierDistanceWalkingRunning",
"m",
6000,
],
["FLIGHTS_CLIMBED", "HKQuantityTypeIdentifierFlightsClimbed", "flights", 9],
] as const) {
const n = await prisma.$executeRawUnsafe(
`
INSERT INTO measurements
(id, user_id, type, value, unit, source, measured_at, external_id,
aggregation_provenance, created_at, updated_at)
SELECT
'seed-' || $1 || '-' || $2 || '-stats-' || d,
$1, $2::measurement_type, $5 + (d * 7919) % ($5 / 2), $4, 'APPLE_HEALTH',
(($7::date - d)::timestamp + interval '12 hours')
AT TIME ZONE $8 AT TIME ZONE 'UTC',
'stats:' || $3 || ':' || to_char($7::date - d, 'YYYY-MM-DD'),
'EXPORT_XML_SOURCE_MAX', now(), now()
FROM generate_series(91, $6::int) AS d
`,
USER_ID,
type,
hk,
unit,
base,
days,
ANCHOR,
ZONE,
);
total += n;
log(`${type} (stats:)`, n);
}
// Sleep: 30 segments a night from 23:00 local, 16 minutes apart, rotating
// through the stages. Distinct measured_at per segment.
const sleep = await prisma.$executeRawUnsafe(
`
INSERT INTO measurements
(id, user_id, type, value, unit, source, measured_at, external_id,
sleep_stage, created_at, updated_at)
SELECT
'seed-' || $1 || '-sleep-' || d || '-' || s,
$1, 'SLEEP_DURATION', 16, 'minutes', 'APPLE_HEALTH',
(($3::date - d)::timestamp - interval '1 hour' + s * interval '16 minutes')
AT TIME ZONE $4 AT TIME ZONE 'UTC',
'hk-' || md5('sleep' || d || '-' || s),
(ARRAY['CORE','DEEP','CORE','REM','AWAKE','CORE']::sleep_stage[])[1 + s % 6],
now(), now()
FROM generate_series(1, $2::int) AS d, generate_series(0, 29) AS s
`,
USER_ID,
days,
ANCHOR,
ZONE,
);
total += sleep;
log("SLEEP_DURATION (APPLE_HEALTH)", sleep);
return total;
}
async function seedWorkouts(days: number): Promise<number> {
// About one workout every 29 hours, rotating through five sports.
const count = Math.floor((days * 24) / 29);
return prisma.$executeRawUnsafe(
`
INSERT INTO workouts
(id, user_id, sport_type, started_at, ended_at, duration_sec,
total_energy_kcal, total_distance_m, avg_heart_rate, max_heart_rate,
source, external_id, created_at, updated_at)
SELECT
'seed-' || $1 || '-workout-' || g,
$1,
(ARRAY['walking','running','cycling','hiking','swimming'])[1 + g % 5],
$3::date::timestamp - make_interval(hours => (g * 29)::int) + interval '17 hours',
$3::date::timestamp - make_interval(hours => (g * 29)::int) + interval '17 hours'
+ make_interval(secs => (1800 + (g * 97) % 3600)::int),
(1800 + (g * 97) % 3600)::int,
round((250 + (g * 13) % 400)::numeric, 1),
round((3000 + (g * 211) % 9000)::numeric, 1),
(115 + (g * 7) % 40)::int,
(150 + (g * 5) % 35)::int,
'APPLE_HEALTH',
'hk-workout-' || md5($1 || g::text),
now(), now()
FROM generate_series(0, $2::int - 1) AS g
`,
USER_ID,
count,
ANCHOR,
);
}
async function seedMood(days: number): Promise<number> {
const span = Math.min(days, 3 * 365);
return prisma.$executeRawUnsafe(
`
INSERT INTO mood_entries
(id, user_id, date, mood, score, mood_a1, stress_a2, energy_a3,
source, mood_logged_at, tz, synced_at, created_at, updated_at)
SELECT
'seed-' || $1 || '-mood-' || d,
$1,
to_char($3::date - d, 'YYYY-MM-DD'),
(ARRAY['LAUSIG','SCHLECHT','OKAY','GUT','SUPER_GUT'])[1 + (d * 7) % 5],
1 + (d * 7) % 5,
2 * ((d * 7) % 5) + 1,
(d * 3) % 11,
(d * 5) % 11,
'MANUAL',
(($3::date - d)::timestamp + interval '20 hours') AT TIME ZONE $4 AT TIME ZONE 'UTC',
$4,
now(), now(), now()
FROM generate_series(1, $2::int) AS d
`,
USER_ID,
span,
ANCHOR,
ZONE,
);
}
async function seedMedications(days: number): Promise<number> {
const span = Math.min(days, 3 * 365);
const meds = [
{ key: "a", name: "Synthetic A", dose: "5 mg", window: ["08:00", "09:00"] },
{
key: "b",
name: "Synthetic B",
dose: "100 mg",
window: ["08:00", "09:00"],
},
{
key: "c",
name: "Synthetic C",
dose: "20 mg",
window: ["20:00", "21:00"],
},
];
let events = 0;
for (const m of meds) {
const medId = `seed-${USER_ID}-med-${m.key}`;
await prisma.$executeRawUnsafe(
`
INSERT INTO medications
(id, user_id, name, dose, starts_on, created_at, updated_at)
VALUES ($1, $2, $3, $4, ($5::date - $6::int)::timestamp, now(), now())
`,
medId,
USER_ID,
m.name,
m.dose,
ANCHOR,
span,
);
await prisma.$executeRawUnsafe(
`
INSERT INTO medication_schedules
(id, medication_id, window_start, window_end, schedule_type)
VALUES ($1, $2, $3, $4, 'SCHEDULED')
`,
`${medId}-schedule`,
medId,
m.window[0],
m.window[1],
);
// One intake event per day; about one in twelve skipped, the rest taken
// a few minutes into the window.
events += await prisma.$executeRawUnsafe(
`
INSERT INTO medication_intake_events
(id, user_id, medication_id, scheduled_for, taken_at, skipped,
attribution_source, source, created_at, updated_at)
SELECT
$1 || '-intake-' || d,
$2,
$1,
(($3::date - d)::timestamp + $5::time) AT TIME ZONE $6 AT TIME ZONE 'UTC',
CASE WHEN d % 12 = 0 THEN NULL
ELSE (($3::date - d)::timestamp + $5::time
+ make_interval(mins => (d * 7) % 40)) AT TIME ZONE $6 AT TIME ZONE 'UTC'
END,
d % 12 = 0,
'AUTO',
'WEB',
now(), now()
FROM generate_series(1, $4::int) AS d
`,
medId,
USER_ID,
ANCHOR,
span,
m.window[0],
ZONE,
);
}
return events;
}
async function main(): Promise<void> {
const days = Math.round(YEARS * 365);
const started = Date.now();
if (RESET) {
await prisma.$executeRawUnsafe(`DELETE FROM users WHERE id = $1`, USER_ID);
}
await prisma.$executeRawUnsafe(
`INSERT INTO users (id, username, timezone, onboarding_completed_at, updated_at)
VALUES ($1, $1, $2, now(), now())
ON CONFLICT (id) DO NOTHING`,
USER_ID,
ZONE,
);
const measurements = await seedMeasurements(days);
log("measurements total", measurements);
log("workouts", await seedWorkouts(days));
log("mood entries", await seedMood(days));
log("medication intake events", await seedMedications(days));
if (ROLLUPS) {
const folded = await recomputeUserRollups(USER_ID, {
granularities: ALL_GRANULARITIES,
statementTimeoutMs: 600_000,
});
log("rollup rows", folded.rowsUpserted);
}
for (const table of [
"measurements",
"measurement_rollups",
"workouts",
"mood_entries",
"medication_intake_events",
]) {
await prisma.$executeRawUnsafe(`ANALYZE ${table}`);
}
console.log(`done in ${((Date.now() - started) / 1000).toFixed(1)} s`);
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
+263 -128
View File
@@ -2,55 +2,90 @@
/**
* Client-bundle report + budget gate for the Turbopack build.
*
* The webpack-era tooling died on Next 16: `ANALYZE=1` is a silent no-op
* under Turbopack and the old `.next/analyze/client.json` reader had
* nothing to read — so bundle regressions (a statically imported message
* catalog, a duplicated recharts chunk group) shipped invisibly. This
* script derives the signal straight from the build output instead:
* What a reader downloads is a set of chunk files: the shared
* `rootMainFiles` from `build-manifest.json` plus the chunks a page's
* `*_client-reference-manifest.js` names. This script measures that set per
* page and gates it, and separately measures how much distinct client code
* the build holds.
*
* - per-route client-JS totals (gzip) from the Turbopack
* `*_client-reference-manifest.js` files plus the shared
* `rootMainFiles` baseline from `build-manifest.json`;
* - the total gzip weight of every emitted client chunk;
* - a recharts-duplication guard: the number of chunks carrying the
* recharts library fingerprint must stay exactly 1 (the shared
* chart-runtime chunk group);
* - a catalog guard: no message-catalog-fingerprinted chunk may be
* referenced by any route's client-reference manifest or by the
* shared baseline (catalogs load only lazily / via /i18n/<locale>).
* METHOD
*
* First load per page. The union of `rootMainFiles` and the page's
* client-reference chunks, deduplicated by content hash (sha-256 of the
* file), each file gzipped at level 9 and summed. A file that two
* manifests list under different names counts once. Every page in the
* build is measured, not a hand-picked few: a heavy import into a page
* nobody watched is how Zod sat in fifty metric pages for three months.
*
* Across pages. p50, p95 and max of the per-page first loads.
*
* Unique client code. Every emitted chunk is evaluated in a `node:vm`
* sandbox with a stub `TURBOPACK` array, which collects the module
* factories the chunk registers without running any of them. Each factory
* is keyed by its Turbopack module id, which is global to the build, so a
* module the bundler copied into forty chunk groups counts once. (Not by
* the factory's text: each chunk is minified on its own, so the copies of
* one module differ in their mangled local names.) One copy per module,
* from the first chunk in file-name order, without the lazy message
* catalogs, is concatenated in id order and gzipped once. A file that
* registers no factories (the runtime, the polyfills) counts as one unit
* by its content hash.
*
* The old aggregate (every emitted chunk gzipped and summed) is still
* printed, for continuity, but no longer gated: it counts a module once
* per chunk group it lands in, so it rose every time a page got lighter
* by moving code into on-demand chunks.
*
* GATES (bundle-budget.json)
*
* - routeBaselineKbGz: measured first load per page; a page fails when it
* is more than routeDriftAllowanceKbGz over its baseline, or when it is
* a page with no baseline (write one with --write-baselines).
* - firstLoadP95KbGz / firstLoadMaxKbGz: caps on the distribution.
* - uniqueClientKbGz: cap on the distinct client code.
* - maxCatalogKbGz: per lazy message catalog.
* - maxRechartsChunks: chunks carrying the Recharts fingerprint (one, the
* shared chart-runtime group).
* - no message catalog referenced by a page or the shared baseline.
*
* Usage:
* pnpm bundle-report # print the report table
* pnpm bundle-report --check # enforce bundle-budget.json (CI gate)
* pnpm bundle-report # print the report
* pnpm bundle-check # enforce bundle-budget.json
* node scripts/check-bundle-budget.mjs --write-baselines
* # rewrite routeBaselineKbGz from
* # this build (a reviewed change)
* node scripts/check-bundle-budget.mjs --dir <build dir> [--json <file>]
*
* 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`.
* Numbers are KB gzip. A local arm64 build measures each page about 1.5 KB
* lighter than the CI runner; the drift allowance covers that and ordinary
* week-to-week movement, while the step changes the gate exists for (a
* statically imported catalog, a duplicated chart library, Zod on a page
* that had none: 60 to 100 KB each) are several times larger.
*/
import { readFileSync, readdirSync, existsSync } from "node:fs";
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { join } from "node:path";
import vm from "node:vm";
import { gzipSync } from "node:zlib";
const CHECK = process.argv.includes("--check");
const BUILD_DIR = join(process.cwd(), ".next");
const argv = process.argv.slice(2);
const flag = (name) => argv.includes(name);
const option = (name) => {
const i = argv.indexOf(name);
return i >= 0 ? argv[i + 1] : undefined;
};
const CHECK = flag("--check");
const WRITE_BASELINES = flag("--write-baselines");
const BUILD_DIR = option("--dir") ?? join(process.cwd(), ".next");
const JSON_OUT = option("--json");
const APP_DIR = join(BUILD_DIR, "server", "app");
const BUDGET_PATH = join(process.cwd(), "bundle-budget.json");
if (!existsSync(join(BUILD_DIR, "build-manifest.json"))) {
console.error(
"check-bundle-budget: no .next/build-manifest.json — run `pnpm build` first.",
`check-bundle-budget: no ${join(BUILD_DIR, "build-manifest.json")} — run \`pnpm build\` first.`,
);
process.exit(2);
}
@@ -58,30 +93,47 @@ if (!existsSync(join(BUILD_DIR, "build-manifest.json"))) {
/** Recharts library fingerprint (present once in the shared runtime chunk). */
const RECHARTS_MARK = "CartesianGrid";
/**
* Message-catalog fingerprint. The key set is identical across all six
* locales, so a KEY-position match ("typeTimeInDaylight" directly followed
* by a colon, in either the plain or the JSON.parse-escaped chunk encoding)
* marks a bundled catalog. Source modules only ever carry the namespaced
* call-site form (`measurements.typeTimeInDaylight`), which matches neither.
* Message-catalog fingerprint. The key set is identical across all locales,
* so a KEY-position match ("typeTimeInDaylight" directly followed by a colon,
* in either the plain or the JSON.parse-escaped chunk encoding) marks a
* bundled catalog. Source modules only ever carry the namespaced call-site
* form (`measurements.typeTimeInDaylight`), which matches neither.
*/
const CATALOG_MARKS = ['"typeTimeInDaylight":', 'typeTimeInDaylight\\":'];
const sizeCache = new Map();
const sha = (buf) => createHash("sha256").update(buf).digest("hex");
const gz = (buf) => gzipSync(buf, { level: 9 }).length;
const kb = (bytes) => bytes / 1024;
const fmt = (bytes) => `${kb(bytes).toFixed(0)} KB`;
const round1 = (n) => Math.round(n * 10) / 10;
const fileCache = new Map();
function chunkInfo(file) {
if (!sizeCache.has(file)) {
if (!fileCache.has(file)) {
let info = {
hash: `missing:${file}`,
raw: 0,
gz: 0,
recharts: false,
catalog: false,
};
try {
const buf = readFileSync(join(BUILD_DIR, file));
sizeCache.set(file, {
info = {
hash: sha(buf),
raw: buf.length,
gz: gzipSync(buf, { level: 9 }).length,
gz: gz(buf),
recharts: buf.includes(RECHARTS_MARK),
catalog: CATALOG_MARKS.some((m) => buf.includes(m)),
});
buf,
};
} catch {
sizeCache.set(file, { raw: 0, gz: 0, recharts: false, catalog: false });
// A manifest naming a file the build did not emit: counted as zero and
// reported by its missing hash rather than crashing the report.
}
fileCache.set(file, info);
}
return sizeCache.get(file);
return fileCache.get(file);
}
function routeChunks(manifestFile) {
@@ -97,24 +149,54 @@ function routeChunks(manifestFile) {
return files;
}
function findRouteManifests(dir, prefix = "") {
function findPageManifests(dir, prefix = "") {
let out = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, entry.name);
if (entry.isDirectory()) {
out = out.concat(findRouteManifests(p, `${prefix}/${entry.name}`));
} else if (entry.name.endsWith("_client-reference-manifest.js")) {
out.push({
route: `${prefix}/${entry.name.replace("_client-reference-manifest.js", "")}`,
file: p,
});
out = out.concat(findPageManifests(p, `${prefix}/${entry.name}`));
} else if (entry.name === "page_client-reference-manifest.js") {
out.push({ route: `${prefix}/page`, file: p });
}
}
return out;
}
const kb = (bytes) => bytes / 1024;
const fmt = (bytes) => `${kb(bytes).toFixed(0)} KB`;
function percentile(sorted, p) {
if (sorted.length === 0) return 0;
const i = Math.min(
sorted.length - 1,
Math.ceil((p / 100) * sorted.length) - 1,
);
return sorted[Math.max(0, i)];
}
/**
* The `[module id, factory source]` pairs a Turbopack chunk registers,
* without running them: `TURBOPACK.push([script, id, factory, id, factory,
* …])`. Null when the file is not in that shape.
*/
function chunkFactories(buf) {
const sandbox = { TURBOPACK: [] };
sandbox.globalThis = sandbox;
sandbox.self = sandbox;
try {
vm.runInNewContext(buf.toString("utf8"), sandbox, { timeout: 5000 });
} catch {
return null;
}
const modules = [];
for (const entry of sandbox.TURBOPACK) {
if (!Array.isArray(entry)) continue;
for (let i = 1; i < entry.length; i++) {
if (typeof entry[i] !== "function") continue;
const id = entry[i - 1];
if (typeof id !== "number" && typeof id !== "string") continue;
modules.push([String(id), entry[i].toString()]);
}
}
return modules.length > 0 ? modules : null;
}
// ── Shared baseline ─────────────────────────────────────────────────────────
const buildManifest = JSON.parse(
@@ -123,88 +205,135 @@ const buildManifest = JSON.parse(
const rootFiles = buildManifest.rootMainFiles ?? [];
let rootGz = 0;
for (const f of rootFiles) rootGz += chunkInfo(f).gz;
const rootCatalogRef = rootFiles.some((f) => chunkInfo(f).catalog);
// ── Per-route totals ────────────────────────────────────────────────────────
const manifests = findRouteManifests(APP_DIR);
const routeTotals = new Map();
for (const { route, file } of manifests) {
let gz = 0;
// ── Per-page first load, deduplicated by content hash ──────────────────────
const pages = new Map();
for (const { route, file } of findPageManifests(APP_DIR)) {
const byHash = new Map();
let catalogRef = false;
for (const chunk of routeChunks(file)) {
for (const chunk of [...rootFiles, ...routeChunks(file)]) {
const info = chunkInfo(chunk);
gz += info.gz;
if (info.catalog) catalogRef = true;
byHash.set(info.hash, info.gz);
if (info.catalog && !rootFiles.includes(chunk)) catalogRef = true;
}
routeTotals.set(route, { gz: gz + rootGz, catalogRef });
let total = 0;
for (const size of byHash.values()) total += size;
pages.set(route, { gz: total, catalogRef });
}
const firstLoads = [...pages.values()].map((p) => p.gz).sort((a, b) => a - b);
const p50 = percentile(firstLoads, 50);
const p95 = percentile(firstLoads, 95);
const max = firstLoads[firstLoads.length - 1] ?? 0;
// ── Whole-build chunk scan ──────────────────────────────────────────────────
// ── Whole build: old aggregate, catalogs, recharts, unique modules ─────────
const chunkDir = join(BUILD_DIR, "static", "chunks");
let totalGz = 0;
let summedGz = 0;
let rechartsChunks = 0;
let catalogChunks = 0;
let catalogGz = 0;
let largestCatalog = { file: "-", gz: 0 };
let largest = { file: "-", gz: 0 };
for (const f of readdirSync(chunkDir)) {
const uniqueUnits = new Map();
for (const f of readdirSync(chunkDir).sort()) {
if (!f.endsWith(".js")) continue;
const info = chunkInfo(join("static", "chunks", f));
totalGz += info.gz;
summedGz += info.gz;
if (info.recharts) rechartsChunks += 1;
if (info.catalog) {
catalogChunks += 1;
catalogGz += info.gz;
if (info.gz > largestCatalog.gz) largestCatalog = { file: f, gz: info.gz };
continue;
}
const modules = info.buf ? chunkFactories(info.buf) : null;
if (modules) {
for (const [id, source] of modules) {
if (!uniqueUnits.has(`m:${id}`)) uniqueUnits.set(`m:${id}`, source);
}
} else if (info.buf) {
uniqueUnits.set(`f:${info.hash}`, info.buf.toString("utf8"));
}
if (info.gz > largest.gz) largest = { file: f, gz: info.gz };
}
const rootCatalogRef = rootFiles.some((f) => chunkInfo(f).catalog);
const uniqueKeys = [...uniqueUnits.keys()].sort();
const uniqueGz = gz(
Buffer.from(uniqueKeys.map((k) => uniqueUnits.get(k)).join("\n"), "utf8"),
);
// ── Report ──────────────────────────────────────────────────────────────────
const budget = existsSync(BUDGET_PATH)
? JSON.parse(readFileSync(BUDGET_PATH, "utf8"))
: null;
const baselines = budget?.routeBaselineKbGz ?? {};
const allowanceKb = budget?.routeDriftAllowanceKbGz ?? 0;
console.log(`pages measured: ${pages.size}`);
console.log(`shared baseline (rootMainFiles): ${fmt(rootGz)} gz`);
console.log(`all client chunks: ${fmt(totalGz)} gz`);
console.log(
` lazy message catalogs: ${fmt(catalogGz)} gz (${catalogChunks} chunks)`,
);
console.log(` other client chunks: ${fmt(totalGz - catalogGz)} gz`);
console.log(
` largest message catalog: ${fmt(largestCatalog.gz)} gz (${largestCatalog.file})`,
`first load across pages: p50 ${fmt(p50)}, p95 ${fmt(p95)}, max ${fmt(max)} gz`,
);
console.log(
`largest chunk: ${fmt(largest.gz)} gz ${largest.file}`,
`unique client code: ${fmt(uniqueGz)} gz (${uniqueKeys.length} distinct modules, catalogs excluded)`,
);
console.log(
`lazy message catalogs: ${fmt(catalogGz)} gz (${catalogChunks} chunks, largest ${fmt(largestCatalog.gz)} ${largestCatalog.file})`,
);
console.log(
`summed chunks, not gated: ${fmt(summedGz - catalogGz)} gz (every emitted chunk once, catalogs excluded)`,
);
console.log(`recharts-fingerprint chunks: ${rechartsChunks}`);
console.log("");
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 baseline = baselines[route];
if (baseline == null) {
console.log(`${fmt(gz).padStart(8)} gz ${route}`);
continue;
}
const delta = kb(gz) - baseline;
const sign = delta >= 0 ? "+" : "−";
const rows = [...pages.entries()]
.map(([route, { gz: size }]) => {
const baseline = baselines[route];
return {
route,
kb: round1(kb(size)),
baseline: baseline ?? null,
delta: baseline == null ? null : round1(kb(size) - baseline),
};
})
.sort((a, b) => (b.delta ?? Infinity) - (a.delta ?? Infinity) || b.kb - a.kb);
console.log("largest movers against their baseline:");
for (const r of rows.slice(0, 15)) {
const delta =
r.delta == null
? "no baseline"
: `${r.delta >= 0 ? "+" : "−"}${Math.abs(r.delta).toFixed(1)} KB of ${allowanceKb}`;
console.log(
`${fmt(gz).padStart(8)} gz ${route} (baseline ${baseline} KB, ` +
`${sign}${Math.abs(delta).toFixed(1)} KB of ${allowanceKb} KB allowed, ` +
`cap ${capFor(route)} KB)`,
`${`${r.kb.toFixed(0)} KB`.padStart(8)} gz ${r.route} (${delta})`,
);
}
const report = {
pages: Object.fromEntries(rows.map((r) => [r.route, r.kb])),
firstLoadKbGz: {
p50: round1(kb(p50)),
p95: round1(kb(p95)),
max: round1(kb(max)),
},
uniqueClientKbGz: round1(kb(uniqueGz)),
summedChunksKbGz: round1(kb(summedGz - catalogGz)),
catalogKbGz: round1(kb(catalogGz)),
largestCatalogKbGz: round1(kb(largestCatalog.gz)),
rechartsChunks,
};
if (JSON_OUT) writeFileSync(JSON_OUT, `${JSON.stringify(report, null, 2)}\n`);
if (WRITE_BASELINES) {
if (!budget) {
console.error("check-bundle-budget: bundle-budget.json missing.");
process.exit(2);
}
budget.routeBaselineKbGz = Object.fromEntries(
rows
.map((r) => [r.route, Math.round(r.kb)])
.sort(([a], [b]) => a.localeCompare(b)),
);
writeFileSync(BUDGET_PATH, `${JSON.stringify(budget, null, 2)}\n`);
console.log(`\nwrote ${rows.length} page baselines to bundle-budget.json`);
}
if (!CHECK) process.exit(0);
// ── Budget gate ─────────────────────────────────────────────────────────────
@@ -212,42 +341,48 @@ if (!budget) {
console.error("check-bundle-budget: bundle-budget.json missing.");
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) {
// A budget that states no page baselines gates nothing, and would do it
// quietly. A build with no pages is a broken build, not a light one.
if (Object.keys(baselines).length === 0 || pages.size === 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.",
"check-bundle-budget: no page baselines in bundle-budget.json, or no " +
"pages in the build. Measure them with --write-baselines.",
);
process.exit(2);
}
const failures = [];
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;
}
const delta = kb(actual.gz) - baselineKb;
if (delta > allowanceKb) {
for (const r of rows) {
if (r.baseline == null) {
failures.push(
`${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)`,
`${r.route}: ${r.kb} KB gz and no baseline. A new page gets one with --write-baselines in the same change.`,
);
} else if (r.delta > allowanceKb) {
failures.push(
`${r.route}: ${r.kb} KB gz is ${r.delta.toFixed(1)} KB over its ${r.baseline} KB baseline, past the ${allowanceKb} KB drift allowance`,
);
}
}
// The aggregate leaves the lazy message catalogs out. A reader downloads one
// catalog, the one for their language, so counting all of them made every new
// interface language look like a regression. Each catalog is held to its own
// ceiling instead.
const nonCatalogGz = totalGz - catalogGz;
if (budget.totalClientKbGz && kb(nonCatalogGz) > budget.totalClientKbGz) {
for (const route of Object.keys(baselines)) {
if (!pages.has(route)) {
failures.push(
`${route} has a baseline but is not in the build; remove it with --write-baselines`,
);
}
}
if (budget.firstLoadP95KbGz && kb(p95) > budget.firstLoadP95KbGz) {
failures.push(
`client JS without the lazy catalogs ${fmt(nonCatalogGz)} gz exceeds the ${budget.totalClientKbGz} KB budget`,
`first-load p95 ${kb(p95).toFixed(1)} KB gz exceeds the ${budget.firstLoadP95KbGz} KB budget`,
);
}
if (budget.firstLoadMaxKbGz && kb(max) > budget.firstLoadMaxKbGz) {
failures.push(
`largest first load ${kb(max).toFixed(1)} KB gz exceeds the ${budget.firstLoadMaxKbGz} KB budget`,
);
}
if (budget.uniqueClientKbGz && kb(uniqueGz) > budget.uniqueClientKbGz) {
failures.push(
`unique client code ${kb(uniqueGz).toFixed(1)} KB gz exceeds the ${budget.uniqueClientKbGz} KB budget`,
);
}
if (budget.maxCatalogKbGz && kb(largestCatalog.gz) > budget.maxCatalogKbGz) {
@@ -263,12 +398,12 @@ if (
`${rechartsChunks} recharts-fingerprint chunks (budget ${budget.maxRechartsChunks}) — a chart import bypassed the shared chart-runtime boundary`,
);
}
const catalogRefRoutes = [...routeTotals.entries()]
const catalogRefPages = [...pages.entries()]
.filter(([, v]) => v.catalogRef)
.map(([r]) => r);
if (catalogRefRoutes.length > 0 || rootCatalogRef) {
if (catalogRefPages.length > 0 || rootCatalogRef) {
failures.push(
`message catalog statically referenced by ${rootCatalogRef ? "the shared baseline" : catalogRefRoutes.join(", ")} — catalogs must stay lazy (/i18n/<locale> + dynamic import)`,
`message catalog statically referenced by ${rootCatalogRef ? "the shared baseline" : catalogRefPages.join(", ")} — catalogs must stay lazy (/i18n/<locale> + dynamic import)`,
);
}
+1 -1
View File
@@ -28,7 +28,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(__dirname, "..");
const outDir = join(repoRoot, "public", "i18n");
const LOCALES = ["de", "en", "fr", "es", "it", "pl", "ko", "nl"];
const LOCALES = ["de", "en", "fr", "es", "it", "pl", "ko", "nl", "pt-BR"];
mkdirSync(outDir, { recursive: true });
+1 -1
View File
@@ -11,7 +11,7 @@
*/
import { readFileSync, writeFileSync } from "node:fs";
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"];
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl", "pt-BR"];
const file = (loc) => `messages/${loc}.json`;
function read(loc) {
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env node
/**
* Check each module's mutation score against its floor.
*
* node scripts/mutation-floors.mjs [reports/mutation/mutation.json]
*
* Stryker's own `thresholds.break` is one number for the whole run, which
* lets a well-tested module hide a weak one. `stryker.floors.json` names a
* floor per module instead (a module is a set of path prefixes), and this
* script reads the JSON report Stryker writes and fails when any module
* scores below its floor. The score is Stryker's: killed and timed-out
* mutants over every mutant that was valid and covered or not, so a mutant no
* test reaches counts against the module.
*
* Floors sit just below the last measured score. Raise one when a change
* raises the score; a floor that is lowered to let a run pass has stopped
* being a floor.
*/
import { existsSync, readFileSync } from "node:fs";
import { relative, resolve } from "node:path";
const root = resolve(import.meta.dirname, "..");
const reportPath = resolve(
root,
process.argv[2] ?? "reports/mutation/mutation.json",
);
const floorsPath = resolve(root, "stryker.floors.json");
if (!existsSync(reportPath)) {
console.error(
`no mutation report at ${reportPath}; run pnpm exec stryker run`,
);
process.exit(2);
}
const report = JSON.parse(readFileSync(reportPath, "utf8"));
const { modules } = JSON.parse(readFileSync(floorsPath, "utf8"));
const DETECTED = new Set(["Killed", "Timeout"]);
const UNDETECTED = new Set(["Survived", "NoCoverage"]);
const files = Object.entries(report.files ?? {}).map(([path, file]) => [
relative(root, resolve(root, path)).split("\\").join("/"),
file,
]);
let failed = false;
const rows = [];
for (const [name, { paths, floor }] of Object.entries(modules)) {
let detected = 0;
let undetected = 0;
let matched = 0;
for (const [path, file] of files) {
if (!paths.some((prefix) => path.startsWith(prefix))) continue;
matched += 1;
for (const mutant of file.mutants ?? []) {
if (DETECTED.has(mutant.status)) detected += 1;
else if (UNDETECTED.has(mutant.status)) undetected += 1;
}
}
const total = detected + undetected;
const score = total === 0 ? null : (100 * detected) / total;
const ok = matched > 0 && score !== null && score >= floor;
if (!ok) failed = true;
rows.push(
`${ok ? "ok " : "FAIL"} ${name.padEnd(16)} ${
score === null ? "no mutants" : `${score.toFixed(1)} %`
} (floor ${floor} %, ${detected}/${total} mutants, ${matched} files)`,
);
}
console.log(rows.join("\n"));
if (failed) {
console.error(
"\nA module scored below its floor, or the report covered none of its files.",
);
process.exit(1);
}
-296
View File
@@ -1,296 +0,0 @@
/**
* Seed one account shaped like a multi-year Apple Health `export.xml` import,
* for reproducing the maintenance jobs at the size a real heavy account
* reaches (issue #1031: about 1.26 million measurement rows).
*
* DATABASE_URL=postgresql://… pnpm dlx tsx scripts/seed-large-account.ts \
* [--user bench-large] [--years 4] [--pulse-seconds 165]
*
* Never point this at a database that holds real data: it inserts rows under
* the given user id and, with `--reset`, deletes that user first.
*
* The shape follows what the import and the iOS sync actually write:
*
* - dense spot rows (heart rate every `--pulse-seconds`, HRV, SpO2) keyed by
* a hashed sample id, the tier the dense-intraday retention folds;
* - high-frequency mean rows (respiratory rate, gait metrics, audio
* exposure), the tier the daily-mean consolidation folds;
* - one `stats:` daily row per cumulative type and day (what the export
* import writes for steps, energy, distance and flights);
* - per-sample cumulative rows with opaque sample ids for the most recent
* 90 days (what the iOS sync writes, and what the cumulative drain folds);
* - sleep-stage segments every night.
*
* Everything is generated inside Postgres with `generate_series`, so a million
* rows take seconds rather than a million round trips. `ANALYZE` runs at the
* end: a planner reading pre-seed statistics picks plans no production table
* of this size would get.
*/
import { prisma } from "@/lib/db";
function arg(name: string, fallback: string): string {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1]! : fallback;
}
const USER_ID = arg("user", "bench-large");
const YEARS = Number(arg("years", "4"));
const PULSE_SECONDS = Number(arg("pulse-seconds", "165"));
const RESET = process.argv.includes("--reset");
/** A spot series: `perDay` evenly spaced rows per day over the whole span. */
interface SpotSeries {
type: string;
unit: string;
perDay: number;
base: number;
spread: number;
/** Days back from now the series covers. Defaults to the whole span. */
days?: number;
/** externalId prefix; `stats:` rows are generated separately. */
idPrefix: string;
}
async function insertSpot(s: SpotSeries, days: number): Promise<number> {
const step = Math.floor(86_400 / s.perDay);
const count = Math.floor((days * 86_400) / step);
// Jitter stays below the step so every measured_at is distinct per type
// (the (user, type, measured_at, source, sleep_stage) unique index).
const jitter = Math.max(1, Math.min(59, step - 1));
return prisma.$executeRawUnsafe(
`
INSERT INTO measurements
(id, user_id, type, value, unit, source, measured_at, external_id, created_at, updated_at)
SELECT
'seed-' || $1 || '-' || $2 || '-' || g,
$1,
$2::measurement_type,
round(($3 + $4 * sin(g / 37.0) + ($4 / 2) * ((g * 7919) % 100) / 100.0)::numeric, 2),
$5,
'APPLE_HEALTH',
date_trunc('minute', now()) - make_interval(secs => $6 * $7::bigint)
+ make_interval(secs => g * $6 + (g * 31) % $8),
$9 || md5($2 || g::text),
now(), now()
FROM generate_series(0, $7::bigint - 1) AS g
`,
USER_ID,
s.type,
s.base,
s.spread,
s.unit,
step,
count,
jitter,
s.idPrefix,
);
}
async function main(): Promise<void> {
const days = Math.round(YEARS * 365);
if (RESET) {
await prisma.$executeRawUnsafe(`DELETE FROM users WHERE id = $1`, USER_ID);
}
await prisma.$executeRawUnsafe(
`INSERT INTO users (id, username, timezone, updated_at)
VALUES ($1, $1, 'Europe/Berlin', now())
ON CONFLICT (id) DO NOTHING`,
USER_ID,
);
const pulsePerDay = Math.floor(86_400 / PULSE_SECONDS);
const spot: SpotSeries[] = [
{
type: "PULSE",
unit: "bpm",
perDay: pulsePerDay,
base: 62,
spread: 25,
idPrefix: "hk-",
},
{
type: "HEART_RATE_VARIABILITY",
unit: "ms",
perDay: 12,
base: 45,
spread: 20,
idPrefix: "hk-",
},
{
type: "OXYGEN_SATURATION",
unit: "%",
perDay: 24,
base: 96,
spread: 2,
idPrefix: "hk-",
},
{
type: "RESPIRATORY_RATE",
unit: "breaths/min",
perDay: 48,
base: 14,
spread: 3,
idPrefix: "hk-",
},
{
type: "WALKING_SPEED",
unit: "km/h",
perDay: 30,
base: 4.8,
spread: 0.8,
idPrefix: "hk-",
},
{
type: "WALKING_STEP_LENGTH",
unit: "cm",
perDay: 30,
base: 72,
spread: 6,
idPrefix: "hk-",
},
{
type: "WALKING_ASYMMETRY",
unit: "%",
perDay: 30,
base: 3,
spread: 2,
idPrefix: "hk-",
},
{
type: "WALKING_DOUBLE_SUPPORT",
unit: "%",
perDay: 30,
base: 28,
spread: 3,
idPrefix: "hk-",
},
{
type: "AUDIO_EXPOSURE_ENV",
unit: "dB",
perDay: 60,
base: 62,
spread: 12,
idPrefix: "hk-",
},
// The iOS sync's per-sample cumulative rows: dense, recent, opaque ids.
{
type: "ACTIVITY_STEPS",
unit: "steps",
perDay: 100,
base: 90,
spread: 60,
days: 90,
idPrefix: "uuid-",
},
{
type: "ACTIVE_ENERGY_BURNED",
unit: "kcal",
perDay: 400,
base: 1.2,
spread: 0.9,
days: 90,
idPrefix: "uuid-",
},
{
type: "WALKING_RUNNING_DISTANCE",
unit: "m",
perDay: 100,
base: 70,
spread: 50,
days: 90,
idPrefix: "uuid-",
},
{
type: "WEIGHT",
unit: "kg",
perDay: 1,
base: 82,
spread: 1.5,
idPrefix: "hk-",
},
];
let total = 0;
for (const s of spot) {
const n = await insertSpot(s, s.days ?? days);
total += n;
console.log(`${s.type.padEnd(28)} ${String(n).padStart(9)}`);
}
// One export-estimate `stats:` row per cumulative type and local day, at
// local noon — what `flushCumulativeBuckets` in the export import writes.
for (const [type, hk, unit, base] of [
["ACTIVITY_STEPS", "HKQuantityTypeIdentifierStepCount", "steps", 8000],
[
"ACTIVE_ENERGY_BURNED",
"HKQuantityTypeIdentifierActiveEnergyBurned",
"kcal",
550,
],
[
"WALKING_RUNNING_DISTANCE",
"HKQuantityTypeIdentifierDistanceWalkingRunning",
"m",
6000,
],
["FLIGHTS_CLIMBED", "HKQuantityTypeIdentifierFlightsClimbed", "count", 9],
] as const) {
const n = await prisma.$executeRawUnsafe(
`
INSERT INTO measurements
(id, user_id, type, value, unit, source, measured_at, external_id,
aggregation_provenance, created_at, updated_at)
SELECT
'seed-' || $1 || '-' || $2 || '-stats-' || d,
$1, $2::measurement_type, $5 + (d * 7919) % ($5 / 2), $4, 'APPLE_HEALTH',
((current_date - d)::timestamp + interval '12 hours')
AT TIME ZONE 'Europe/Berlin' AT TIME ZONE 'UTC',
'stats:' || $3 || ':' || to_char(current_date - d, 'YYYY-MM-DD'),
'EXPORT_XML_SOURCE_MAX', now(), now()
FROM generate_series(91, $6::int) AS d
`,
USER_ID,
type,
hk,
unit,
base,
days,
);
total += n;
console.log(`${`${type} (stats:)`.padEnd(28)} ${String(n).padStart(9)}`);
}
// Sleep: 30 segments a night from 23:00 local, 16 minutes apart, rotating
// through the stages. Distinct measured_at per segment.
const sleep = await prisma.$executeRawUnsafe(
`
INSERT INTO measurements
(id, user_id, type, value, unit, source, measured_at, external_id,
sleep_stage, created_at, updated_at)
SELECT
'seed-' || $1 || '-sleep-' || d || '-' || s,
$1, 'SLEEP_DURATION', 16, 'minutes', 'APPLE_HEALTH',
((current_date - d)::timestamp - interval '1 hour' + s * interval '16 minutes')
AT TIME ZONE 'Europe/Berlin' AT TIME ZONE 'UTC',
'hk-' || md5('sleep' || d || '-' || s),
(ARRAY['CORE','DEEP','CORE','REM','AWAKE','CORE']::sleep_stage[])[1 + s % 6],
now(), now()
FROM generate_series(1, $2::int) AS d, generate_series(0, 29) AS s
`,
USER_ID,
days,
);
total += sleep;
console.log(`${"SLEEP_DURATION".padEnd(28)} ${String(sleep).padStart(9)}`);
await prisma.$executeRawUnsafe(`ANALYZE measurements`);
console.log(`${"total".padEnd(28)} ${String(total).padStart(9)}`);
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
+1 -1
View File
@@ -18,7 +18,7 @@
*/
import { readFileSync, writeFileSync } from "node:fs";
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"];
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl", "pt-BR"];
const BOUNDARY = /(?<=[.!?])\s+(?=[A-ZÄÖÜÀÂÉÈÊÎÔÙÛÇŁŚŹŻĄĆĘŃÓ0-9„"„«])/u;
/**
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env node
/**
* Regenerate a timing file for the duration-balanced sequencer
* (`tests/durations/sequencer.ts`) from Vitest JSON reports.
*
* node scripts/test-durations.mjs <suite> <report.json> [<report.json> …]
*
* `<suite>` is `unit` or `integration` and names the file written,
* `tests/durations/<suite>.json`. Pass every shard's report of one run; a
* file measured twice keeps the longer reading. Files the reports do not
* mention keep their previous value, so a partial run refines the table
* rather than emptying it, and files that no longer exist are dropped.
*
* Produce a report with
* pnpm exec vitest run [--config …] --reporter=default \
* --reporter=json --outputFile.json=report.json
* The nightly shuffled workflow uploads the reports of a CI run, which is
* the better source: CI is where the shards have to balance.
*/
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { relative, resolve } from "node:path";
const root = resolve(import.meta.dirname, "..");
const [suite, ...reports] = process.argv.slice(2);
if (!["unit", "integration"].includes(suite ?? "") || reports.length === 0) {
console.error(
"usage: node scripts/test-durations.mjs <unit|integration> <report.json>…",
);
process.exit(2);
}
const target = resolve(root, "tests/durations", `${suite}.json`);
const previous = existsSync(target)
? JSON.parse(readFileSync(target, "utf8")).files
: {};
const measured = new Map();
for (const report of reports) {
const parsed = JSON.parse(readFileSync(report, "utf8"));
for (const result of parsed.testResults ?? []) {
if (typeof result.startTime !== "number") continue;
if (typeof result.endTime !== "number") continue;
const file = relative(root, result.name).split("\\").join("/");
const seconds = Math.max(0, (result.endTime - result.startTime) / 1000);
measured.set(file, Math.max(measured.get(file) ?? 0, seconds));
}
}
if (measured.size === 0) {
console.error("the reports carry no per-file timings; nothing written");
process.exit(1);
}
const files = {};
const keys = new Set([...Object.keys(previous), ...measured.keys()]);
for (const file of [...keys].sort()) {
if (!existsSync(resolve(root, file))) continue;
const seconds = measured.get(file) ?? previous[file];
files[file] = Math.round(seconds * 10) / 10;
}
writeFileSync(
target,
JSON.stringify(
{
source: `vitest JSON report of ${measured.size} files, written ${new Date().toISOString().slice(0, 10)}`,
files,
},
null,
2,
) + "\n",
);
const total = Object.values(files).reduce((a, b) => a + b, 0);
console.log(
`${target}: ${Object.keys(files).length} files, ${Math.round(total)} s in total`,
);
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env node
/**
* Checks that every shipped locale has a message catalog that parses.
*
* The server reads `messages/<locale>.json` from disk at run time
* (`src/lib/i18n/server-catalogs.ts`); nothing is bundled, so an image that
* lost the directory, or one file of it, builds and starts clean and only
* shows the gap when a page asks for that locale. The Dockerfile runs this
* twice: in the builder against the locale list in `src/lib/i18n/config.ts`,
* writing that list out, and in the runner stage against the catalogs the
* image actually carries.
*
* node scripts/verify-message-catalogs.mjs --dir messages \
* --config src/lib/i18n/config.ts [--write .message-locales]
* node verify-message-catalogs.mjs --dir /app/messages \
* --locales .message-locales
*
* Exits 1 with every problem listed when a catalog is missing, does not parse
* or is not a JSON object, or when the locale list cannot be read.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
function arg(name) {
const index = process.argv.indexOf(`--${name}`);
return index === -1 ? null : (process.argv[index + 1] ?? null);
}
function localesFromConfig(path) {
const source = readFileSync(path, "utf8");
const block = /export const locales = \[([^\]]*)\]/.exec(source);
if (!block) throw new Error(`no \`export const locales = [...]\` in ${path}`);
const list = [...block[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]);
if (list.length === 0) throw new Error(`the locale list in ${path} is empty`);
return list;
}
function localesFromFile(path) {
const list = readFileSync(path, "utf8").split("\n").filter(Boolean);
if (list.length === 0) throw new Error(`the locale list in ${path} is empty`);
return list;
}
const dir = arg("dir");
const config = arg("config");
const listFile = arg("locales");
if (!dir || (!config && !listFile)) {
console.error(
"usage: verify-message-catalogs.mjs --dir <messages> (--config <config.ts> | --locales <file>) [--write <file>]",
);
process.exit(2);
}
let locales;
try {
locales = config ? localesFromConfig(config) : localesFromFile(listFile);
} catch (error) {
console.error(`ERROR: ${error.message}`);
process.exit(1);
}
const problems = [];
for (const locale of locales) {
const path = join(dir, `${locale}.json`);
let text;
try {
text = readFileSync(path, "utf8");
} catch (error) {
problems.push(
`${path}: ${error.code === "ENOENT" ? "missing" : error.message}`,
);
continue;
}
try {
const parsed = JSON.parse(text);
if (
parsed === null ||
typeof parsed !== "object" ||
Array.isArray(parsed)
) {
problems.push(`${path}: not a JSON object`);
}
} catch (error) {
problems.push(`${path}: does not parse (${error.message})`);
}
}
if (problems.length > 0) {
console.error(
"ERROR: message catalogs the server reads at run time are broken:",
);
for (const problem of problems) console.error(` ${problem}`);
process.exit(1);
}
const write = arg("write");
if (write) writeFileSync(write, `${locales.join("\n")}\n`);
console.log(`message catalogs ok: ${locales.join(", ")} in ${dir}`);
@@ -14,10 +14,13 @@
* those.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
import {
readSource,
readSourceText,
walkSourceFiles,
} from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
@@ -30,7 +33,7 @@ function sourceFiles(): string[] {
}
function read(rel: string): string {
return readFileSync(join(SRC, rel), "utf8");
return readSourceText(join(SRC, rel));
}
function filesMatching(re: RegExp): string[] {
@@ -177,7 +180,7 @@ describe("the cookie-only helpers cannot see the acting account", () => {
const COOKIE_ONLY = ["requireAdmin", "requireCookieAuth", "requireFreshMfa"];
it.each(COOKIE_ONLY)("%s resolves the actor and nothing else", (name) => {
const body = functionBody(read("lib/api-handler.ts"), name);
const body = functionBody(readSource("src/lib/api-handler.ts", name), name);
// Non-zero proof, twice over: the slice found a function, and the slice is
// the function it claims to be. A renamed helper must fail here rather
@@ -191,7 +194,7 @@ describe("the cookie-only helpers cannot see the acting account", () => {
});
it("substitutes a data-scope user in exactly two functions", () => {
const src = read("lib/api-handler.ts");
const src = readSource("src/lib/api-handler.ts", "requireRecordAuth");
const record = functionBody(src, "requireRecordAuth");
expect(record).toContain("readActingCarrier");
@@ -292,7 +295,7 @@ describe("ending a grant and clearing the switch are one act", () => {
it("runs the transition and the cleanup in one transaction", () => {
const body = privateFunctionBody(
read("lib/sharing/grants.ts"),
readSource("src/lib/sharing/grants.ts", "endAndClear"),
"endAndClear",
);
// Non-zero proof: a renamed helper fails here rather than asserting
@@ -309,7 +312,7 @@ describe("ending a grant and clearing the switch are one act", () => {
// claim's own transaction (it takes `tx`), so the guard here is that the
// cleanup is part of the same function as the ending.
const body = functionBody(
read("lib/sharing/grants.ts"),
readSource("src/lib/sharing/grants.ts", "settleHandoverGuardianGrant"),
"settleHandoverGuardianGrant",
);
expect(body.length).toBeGreaterThan(0);
@@ -26,14 +26,17 @@
* another module would slip it; `AI_ROUTE_PATTERNS` lists the literal shapes
* the tree uses today.
*/
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { AiCapabilityKey } from "@/lib/ai/capabilities/types";
import { stripComments, walkSourceFiles } from "./helpers/source-files";
import {
readSourceText,
stripComments,
walkSourceFiles,
} from "./helpers/source-files";
const SRC = path.resolve(__dirname, "..");
@@ -64,7 +67,7 @@ const FEATURE_FLAG_SWEEP = NON_TEST_FILES.filter(
);
const read = (rel: string) =>
stripComments(readFileSync(path.join(SRC, rel), "utf8"));
stripComments(readSourceText(path.join(SRC, rel)));
/** Request literals of routes that call a model or serve model text. */
const AI_ROUTE_PATTERNS: readonly RegExp[] = [
@@ -48,6 +48,7 @@ import {
convertHkValue,
isConvertibleHkUnit,
} from "@/lib/measurements/hk-units";
import { readSource } from "@/__tests__/helpers/source-files";
const ENTRIES = Object.values(APPLE_HEALTH_TYPE_MAP);
@@ -193,10 +194,10 @@ describe("Apple Health mapping — the record's own unit (issue #944)", () => {
"utf8",
);
expect(archive).toContain(optIn);
const batch = readFileSync(
join(process.cwd(), "src/app/api/measurements/batch/route.ts"),
"utf8",
);
const batch = readSource("src/app/api/measurements/batch/route.ts", [
"export const POST",
"hkIdentifier",
]);
expect(
batch,
"the batch route opted into record-unit conversion",
@@ -208,8 +208,45 @@ const ROUND_TRIP_TEST = "tests/integration/backup-round-trip.test.ts";
const REPO_ROOT = resolve(__dirname, "../..");
/**
* Every read below is memoised: the two-ended checks ask each declared file
* about each model and each of its relations, and reading the schema and the
* files again for every question made those checks slow enough to time out on
* a loaded runner.
*/
const sourceCache = new Map<string, string>();
function read(relativePath: string): string {
return readFileSync(resolve(REPO_ROOT, relativePath), "utf8");
let text = sourceCache.get(relativePath);
if (text === undefined) {
text = readFileSync(resolve(REPO_ROOT, relativePath), "utf8");
sourceCache.set(relativePath, text);
}
return text;
}
const flatCache = new Map<string, string>();
/** A declared file, whitespace-flattened so a call broken across lines matches. */
function flat(relativePath: string): string {
let text = flatCache.get(relativePath);
if (text === undefined) {
text = read(relativePath).replace(/\s+/g, " ");
flatCache.set(relativePath, text);
}
return text;
}
let schemaModelBodies: Array<[string, string]> | null = null;
/** Every schema model's name and body, read once. */
function modelBodies(): Array<[string, string]> {
schemaModelBodies ??= [
...readFileSync(SCHEMA_PATH, "utf8").matchAll(
/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm,
),
].map(([, name, body]) => [name, body]);
return schemaModelBodies;
}
/** `Measurement` → `measurement`, the Prisma delegate name. */
@@ -234,15 +271,19 @@ function delegateName(model: string): string {
* route outright: the guard stayed green on all twelve checks, because the
* cycle file was answering a question about a different table.
*/
const parentsCache = new Map<
string,
Array<{ field: string; parent: string }>
>();
function relationParents(model: string): Array<{
field: string;
parent: string;
}> {
const source = readFileSync(SCHEMA_PATH, "utf8");
const cached = parentsCache.get(model);
if (cached) return cached;
const found: Array<{ field: string; parent: string }> = [];
for (const [, parent, body] of source.matchAll(
/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm,
)) {
for (const [parent, body] of modelBodies()) {
for (const line of body.split("\n")) {
const match = /^\s*(\w+)\s+(\w+)(\[\])?\??\s*(@|$)/.exec(line);
if (match && match[2] === model) {
@@ -250,6 +291,7 @@ function relationParents(model: string): Array<{
}
}
}
parentsCache.set(model, found);
return found;
}
@@ -261,6 +303,8 @@ const WRITE_RELATION_OPS = /\{\s*create\b/;
/** Does `source` call a matching operation on `model`'s own Prisma delegate? */
function callsDelegate(source: string, model: string, ops: RegExp): boolean {
const delegate = delegateName(model);
// The expression below names the delegate; a file without it cannot match.
if (!source.includes(delegate)) return false;
for (const match of source.matchAll(
new RegExp(`\\.${delegate}\\s*\\.\\s*(\\w+)`, "g"),
)) {
@@ -270,15 +314,19 @@ function callsDelegate(source: string, model: string, ops: RegExp): boolean {
}
/** Every model a relation field of this name points at, across the schema. */
const targetsCache = new Map<string, Set<string>>();
function targetsOfRelationField(field: string): Set<string> {
const source = readFileSync(SCHEMA_PATH, "utf8");
const cached = targetsCache.get(field);
if (cached) return cached;
const targets = new Set<string>();
for (const [, body] of source.matchAll(/^model\s+\w+\s*\{([\s\S]*?)^\}/gm)) {
for (const [, body] of modelBodies()) {
for (const line of body.split("\n")) {
const match = /^\s*(\w+)\s+(\w+)(\[\])?\??\s*(@|$)/.exec(line);
if (match && match[1] === field) targets.add(match[2]);
}
}
targetsCache.set(field, targets);
return targets;
}
@@ -301,10 +349,10 @@ function touches(
relationOps: RegExp,
): boolean {
return files.some((file) => {
// Whitespace-flattened so a call broken across lines still matches.
const source = read(file).replace(/\s+/g, " ");
const source = flat(file);
if (callsDelegate(source, model, delegateOps)) return true;
return relationParents(model).some(({ field, parent }) => {
if (!source.includes(field)) return false;
if (
!new RegExp(`\\b${field}\\s*:\\s*(?:${relationOps.source})`).test(
source,
@@ -322,7 +370,7 @@ describe("every backed-up model travels both ways, or is named as debt", () => {
it("does not count a nested relation read as a restore write", () => {
expect(
touches(
["src/lib/export/restore-backup.ts"],
["src/lib/export/measurements-restore.ts"],
"User",
WRITE_OPS,
WRITE_RELATION_OPS,
@@ -565,11 +613,11 @@ describe("tables outside the schema have a backup verdict too", () => {
it("carries the medication category through both ends", () => {
const writer = readFileSync(
join(SRC, "lib/export/full-backup-payload.ts"),
join(SRC, "lib/export/medications-backup.ts"),
"utf8",
);
const restore = readFileSync(
join(SRC, "lib/export/restore-backup.ts"),
join(SRC, "lib/export/medications-restore.ts"),
"utf8",
);
expect(writer).toMatch(/getMedicationCategories\(/);
@@ -12,7 +12,7 @@ import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const READERS = [
"src/lib/export/restore-backup.ts",
"src/lib/export/restore-preflight.ts",
"src/app/api/admin/backups/[id]/summary/route.ts",
"src/app/api/admin/backups/[id]/download/route.ts",
"src/app/api/admin/backups/upload/route.ts",
+54 -11
View File
@@ -9,6 +9,13 @@
*
* The one static importer allowed is the OpenAPI registry, which only the
* spec generator loads.
*
* The schema is composed from one slice per domain (`<domain>-backup-schema.ts`
* beside the domain's export and restore, the shared primitives in
* `backup-schema-kit.ts`). A slice builds its part of the 12 MB the moment it
* is evaluated, so the same rule holds for every slice: it is reached through
* `src/lib/validations/backup.ts` and from other slices, and from nothing
* else, however its domain module is used.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
@@ -37,27 +44,54 @@ const STATIC_IMPORT =
/^import\s+(type\s+)?([^;]*?)\s+from\s+["']@\/lib\/validations\/backup["']/gm;
const DYNAMIC_IMPORT = /import\(\s*["']@\/lib\/validations\/backup["']\s*\)/g;
/** `import … from` a schema slice or the kit, capturing what precedes it. */
const SLICE_IMPORT =
/^import\s+(type\s+)?([^;]*?)\s+from\s+["']@\/lib\/(?:export\/[\w-]+-backup-schema|export\/backup-schema-kit|cycle\/backup-schema)["']/gm;
/** A slice module itself, by its path under `src/`. */
function isSlice(rel: string): boolean {
return (
/^lib\/export\/[\w-]+-backup-schema\.ts$/.test(rel) ||
rel === "lib/export/backup-schema-kit.ts" ||
rel === "lib/cycle/backup-schema.ts"
);
}
function isTypeOnly(match: RegExpMatchArray): boolean {
return (
match[1] !== undefined ||
(/^\{[^}]*\}$/.test(match[2]) &&
match[2]
.slice(1, -1)
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.every((s) => s.startsWith("type ")))
);
}
describe("the backup payload schema loads on first use", () => {
const files = sourceFiles(ROOT);
const staticValueImporters: string[] = [];
let dynamicImports = 0;
let typeImports = 0;
const sliceFiles: string[] = [];
const sliceValueImporters: string[] = [];
let slicesComposed = 0;
for (const file of files) {
const rel = relative(ROOT, file);
if (rel === "lib/validations/backup.ts") continue;
const text = readFileSync(file, "utf8");
if (isSlice(rel)) sliceFiles.push(rel);
for (const match of text.matchAll(SLICE_IMPORT)) {
if (isTypeOnly(match)) continue;
if (rel === "lib/validations/backup.ts") slicesComposed++;
else if (!isSlice(rel)) sliceValueImporters.push(rel);
}
if (rel === "lib/validations/backup.ts") continue;
for (const match of text.matchAll(STATIC_IMPORT)) {
const typeOnly =
match[1] !== undefined ||
(/^\{[^}]*\}$/.test(match[2]) &&
match[2]
.slice(1, -1)
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.every((s) => s.startsWith("type ")));
if (typeOnly) typeImports++;
if (isTypeOnly(match)) typeImports++;
else if (!ALLOWED_STATIC.has(rel)) staticValueImporters.push(rel);
}
dynamicImports += [...text.matchAll(DYNAMIC_IMPORT)].length;
@@ -67,6 +101,15 @@ describe("the backup payload schema loads on first use", () => {
expect(staticValueImporters).toEqual([]);
});
it("reaches no schema slice except through the payload schema", () => {
expect(sliceValueImporters).toEqual([]);
});
it("finds the slices and their composition (no vacuous pass)", () => {
expect(sliceFiles.length).toBeGreaterThanOrEqual(29);
expect(slicesComposed).toBeGreaterThanOrEqual(28);
});
it("finds the loaders it guards (an empty match set would pass vacuously)", () => {
// upload, restore, preview (store + summary route), download, streamed reader
expect(dynamicImports).toBeGreaterThanOrEqual(5);
@@ -0,0 +1,50 @@
{
"$comment": "Runtime import cycles under src/: the back-edge count and the strongly connected components. Maintained by src/__tests__/import-cycle-guard.test.ts.",
"cycles": 13,
"components": [
[
"components/command-palette/command-palette.tsx",
"components/command-palette/palette-store.ts",
"components/layout/global-shortcuts.tsx"
],
[
"lib/analytics/compliance.ts",
"lib/analytics/compliance/display.ts",
"lib/analytics/compliance/ledger.ts",
"lib/analytics/compliance/occurrences.ts",
"lib/analytics/compliance/windows.ts",
"lib/medications/scheduling/band-minter.ts",
"lib/medications/scheduling/cadence.ts",
"lib/medications/scheduling/compliance.ts",
"lib/medications/scheduling/schedule-eras.ts"
],
[
"lib/auth/audit.ts",
"lib/geo.ts",
"lib/integrations/oauth-kit/connect-target.ts",
"lib/jobs/offhost-purge.ts",
"lib/managed-profiles/lifecycle.ts",
"lib/notifications/channel-state.ts",
"lib/notifications/delivery-identity.ts",
"lib/notifications/dispatch-localised.ts",
"lib/notifications/dispatcher.ts",
"lib/sharing/grants.ts"
],
[
"lib/cycle/gate.ts",
"lib/modules/gate.ts"
],
[
"lib/insights/features.ts",
"lib/insights/signals-of-day.ts"
],
[
"lib/insights/mood-aggregates.ts",
"lib/insights/mood-patterns.ts"
],
[
"lib/jobs/boss-instance.ts",
"lib/jobs/boss-ready.ts"
]
]
}
@@ -0,0 +1,23 @@
{
"$comment": "Modules under src/ whose every statement is an export … from. Maintained by src/__tests__/import-cycle-guard.test.ts.",
"count": 16,
"total": 16,
"entries": {
"components/data-list/index.ts": 1,
"components/insights/derived/index.ts": 1,
"components/insights/workout-detail/index.ts": 1,
"lib/ai/coach/tools/index.ts": 1,
"lib/analytics/compliance.ts": 1,
"lib/cycle/index.ts": 1,
"lib/doctor-report-data.ts": 1,
"lib/fhir/resources/index.ts": 1,
"lib/insights/derived/index.ts": 1,
"lib/links/index.ts": 1,
"lib/logging/index.ts": 1,
"lib/mcp/index.ts": 1,
"lib/openapi/routes/insights/index.ts": 1,
"lib/openapi/routes/medications/index.ts": 1,
"lib/record-settings/index.ts": 1,
"lib/validations/medication/index.ts": 1
}
}
@@ -0,0 +1,281 @@
{
"$comment": "route tests that mock @/lib/db wholesale, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
"count": 274,
"total": 274,
"entries": {
"app/api/account/grants/__tests__/invite-access-level.test.ts": 1,
"app/api/account/grants/__tests__/invite-recent-proof.test.ts": 1,
"app/api/account/grants/__tests__/invite-scope-and-manage.test.ts": 1,
"app/api/activity/__tests__/routes.test.ts": 1,
"app/api/admin/ai-settings/__tests__/route.test.ts": 1,
"app/api/admin/audit-log/__tests__/route.test.ts": 1,
"app/api/admin/audit-log/actions/__tests__/route.test.ts": 1,
"app/api/admin/backups/__tests__/route.test.ts": 1,
"app/api/admin/backups/[id]/summary/__tests__/route.test.ts": 1,
"app/api/admin/import-apple-health-export/__tests__/route.test.ts": 1,
"app/api/admin/maintenance/measurements/__tests__/status.test.ts": 1,
"app/api/admin/maintenance/pulse-day-mean/__tests__/route.test.ts": 1,
"app/api/admin/notifications/diagnostic/__tests__/route.test.ts": 1,
"app/api/admin/notifications/health/__tests__/route.test.ts": 1,
"app/api/admin/notifications/reminder-check/__tests__/route.test.ts": 1,
"app/api/admin/notifications/test/__tests__/route.test.ts": 1,
"app/api/admin/provider-health/__tests__/route.test.ts": 1,
"app/api/admin/settings/__tests__/route.test.ts": 1,
"app/api/admin/settings/assistant-flags/__tests__/route.test.ts": 1,
"app/api/admin/settings/module-availability/__tests__/route.test.ts": 1,
"app/api/admin/settings/web-push-vapid/generate/__tests__/route.test.ts": 1,
"app/api/admin/status/__tests__/route.test.ts": 1,
"app/api/admin/support-report/__tests__/route.test.ts": 1,
"app/api/admin/users/__tests__/route.test.ts": 1,
"app/api/admin/users/[id]/__tests__/route.test.ts": 1,
"app/api/analytics/__tests__/route.test.ts": 1,
"app/api/anamnesis/facts/__tests__/idempotency-conflict.test.ts": 1,
"app/api/anamnesis/facts/__tests__/route.test.ts": 1,
"app/api/auth/claim/__tests__/route.test.ts": 1,
"app/api/auth/login/__tests__/constant-work.test.ts": 1,
"app/api/auth/login/__tests__/native-token.test.ts": 1,
"app/api/auth/login/__tests__/route.test.ts": 1,
"app/api/auth/login/__tests__/trusted-device.test.ts": 1,
"app/api/auth/me/mfa/disable/__tests__/route.test.ts": 1,
"app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts": 1,
"app/api/auth/mfa/webauthn/verify/__tests__/route.test.ts": 1,
"app/api/auth/native/complete/__tests__/route.test.ts": 1,
"app/api/auth/native/login/__tests__/route.test.ts": 1,
"app/api/auth/native/token/__tests__/route.test.ts": 1,
"app/api/auth/oidc/callback/__tests__/route.test.ts": 1,
"app/api/auth/oidc/native/token/__tests__/route.test.ts": 1,
"app/api/auth/passkey/login-verify/__tests__/native-token.test.ts": 1,
"app/api/auth/passkey/login-verify/__tests__/oidc-only.test.ts": 1,
"app/api/auth/passkey/login-verify/__tests__/refusals.test.ts": 1,
"app/api/auth/passkeys/__tests__/route.test.ts": 1,
"app/api/auth/passkeys/[id]/__tests__/route.test.ts": 1,
"app/api/auth/password/__tests__/route.test.ts": 1,
"app/api/auth/register/__tests__/route.test.ts": 1,
"app/api/biomarkers/__tests__/error-contract.test.ts": 1,
"app/api/biomarkers/[id]/__tests__/delete-record-auth.test.ts": 1,
"app/api/biomarkers/[id]/__tests__/route.test.ts": 1,
"app/api/c/[token]/unlock/__tests__/route.test.ts": 1,
"app/api/coach/about-me/__tests__/route-concurrency.test.ts": 1,
"app/api/coach/about-me/__tests__/route-module-gate.test.ts": 1,
"app/api/coach/about-me/adopt/__tests__/route.test.ts": 1,
"app/api/coach/plans/__tests__/route.test.ts": 1,
"app/api/coach/reminders/__tests__/route.test.ts": 1,
"app/api/coach/suggested-actions/__tests__/route.test.ts": 1,
"app/api/consent/ai/__tests__/route.test.ts": 1,
"app/api/consent/ai/latest/__tests__/route.test.ts": 1,
"app/api/custom-metrics/__tests__/route.test.ts": 1,
"app/api/custom-metrics/[id]/__tests__/route.test.ts": 1,
"app/api/custom-metrics/[id]/entries/__tests__/note-encryption.test.ts": 1,
"app/api/custom-metrics/[id]/entries/__tests__/route.test.ts": 1,
"app/api/custom-metrics/[id]/entries/__tests__/soft-delete-restore.test.ts": 1,
"app/api/cycle/__tests__/external-id-floor.test.ts": 1,
"app/api/cycle/calendar/__tests__/day-entries.test.ts": 1,
"app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts": 1,
"app/api/cycle/day-logs/bulk/__tests__/route.test.ts": 1,
"app/api/cycle/insights/__tests__/route-locale.test.ts": 1,
"app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts": 1,
"app/api/daily/digest/__tests__/route.test.ts": 1,
"app/api/daily/digest/dismiss/__tests__/route.test.ts": 1,
"app/api/dashboard/chart-overlay-prefs/__tests__/route.test.ts": 1,
"app/api/dashboard/snapshot/__tests__/route.test.ts": 1,
"app/api/dashboard/summary/__tests__/module-gate.test.ts": 1,
"app/api/dashboard/summary/__tests__/route.test.ts": 1,
"app/api/dashboard/widgets/__tests__/route.test.ts": 1,
"app/api/dawarich/places/__tests__/route.test.ts": 1,
"app/api/devices/__tests__/route.test.ts": 1,
"app/api/devices/[id]/__tests__/route.test.ts": 1,
"app/api/documents/inbound/__tests__/route.test.ts": 1,
"app/api/documents/inbound/[id]/__tests__/route.test.ts": 1,
"app/api/documents/inbound/[id]/chat/__tests__/route.test.ts": 1,
"app/api/documents/inbound/[id]/extract/__tests__/route.test.ts": 1,
"app/api/documents/inbound/[id]/index/__tests__/route.test.ts": 1,
"app/api/documents/inbound/[id]/original/__tests__/route.test.ts": 1,
"app/api/documents/inbound/[id]/suggest/__tests__/route.test.ts": 1,
"app/api/documents/inbound/[id]/summary/__tests__/route.test.ts": 1,
"app/api/documents/inbound/[id]/thumbnail/__tests__/route.test.ts": 1,
"app/api/documents/inbound/layout/__tests__/route.test.ts": 1,
"app/api/documents/inbound/reindex/__tests__/route.test.ts": 1,
"app/api/documents/inbound/usage/__tests__/route.test.ts": 1,
"app/api/encounters/procedures/__tests__/route.test.ts": 1,
"app/api/environment/backfill/__tests__/route.test.ts": 1,
"app/api/export/__tests__/measurement-streaming.test.ts": 1,
"app/api/export/__tests__/per-type-routes.test.ts": 1,
"app/api/export/__tests__/soft-delete-filter.test.ts": 1,
"app/api/export/encrypted/__tests__/route.test.ts": 1,
"app/api/export/health-record/__tests__/route.test.ts": 1,
"app/api/feature-flags/__tests__/route.test.ts": 1,
"app/api/fitbit/connect/__tests__/route.test.ts": 1,
"app/api/fitbit/credentials/__tests__/route.test.ts": 1,
"app/api/gamification/achievements/__tests__/hidden-redaction.test.ts": 1,
"app/api/gamification/achievements/__tests__/ios-format.test.ts": 1,
"app/api/gamification/achievements/__tests__/module-gate.test.ts": 1,
"app/api/gamification/achievements/__tests__/writes-nothing.test.ts": 1,
"app/api/google-health/credentials/__tests__/route.test.ts": 1,
"app/api/health/__tests__/route.test.ts": 1,
"app/api/illness/episodes/[id]/__tests__/body-site.test.ts": 1,
"app/api/illness/episodes/[id]/day-logs/__tests__/route.test.ts": 1,
"app/api/illness/episodes/[id]/restore/__tests__/route.test.ts": 1,
"app/api/import/__tests__/route.test.ts": 1,
"app/api/import/apple-health-export/__tests__/route.test.ts": 1,
"app/api/import/apple-health-export/[jobId]/status/__tests__/route.test.ts": 1,
"app/api/import/csv/__tests__/route.test.ts": 1,
"app/api/import/health-connect-export/__tests__/route.test.ts": 1,
"app/api/ingest/medication/__tests__/route.test.ts": 1,
"app/api/insights/biomarker-assessment/__tests__/route.test.ts": 1,
"app/api/insights/blood-pressure-status/__tests__/route.test.ts": 1,
"app/api/insights/cards/__tests__/route.test.ts": 1,
"app/api/insights/chat/__tests__/golden-transcripts.test.ts": 1,
"app/api/insights/chat/__tests__/route-activity.test.ts": 1,
"app/api/insights/chat/__tests__/route-cancelled-turn.test.ts": 1,
"app/api/insights/chat/__tests__/route-capability-gate.test.ts": 1,
"app/api/insights/chat/__tests__/route-fence-guard.test.ts": 1,
"app/api/insights/chat/__tests__/route-followups.test.ts": 1,
"app/api/insights/chat/__tests__/route-guard-activation.test.ts": 1,
"app/api/insights/chat/__tests__/route-guard-ledger.test.ts": 1,
"app/api/insights/chat/__tests__/route-guard.test.ts": 1,
"app/api/insights/chat/__tests__/route-list-query.test.ts": 1,
"app/api/insights/chat/__tests__/route-snapshot-once.test.ts": 1,
"app/api/insights/chat/__tests__/route-tool-mode.test.ts": 1,
"app/api/insights/chat/__tests__/route-workout-scope.test.ts": 1,
"app/api/insights/chat/[id]/attachments/__tests__/route.test.ts": 1,
"app/api/insights/chat/[id]/messages/[messageId]/trail/__tests__/route.test.ts": 1,
"app/api/insights/chat/fenced/__tests__/route.test.ts": 1,
"app/api/insights/coach-read/__tests__/route.test.ts": 1,
"app/api/insights/coach/facts/__tests__/route-memory.test.ts": 1,
"app/api/insights/coach/facts/__tests__/route.test.ts": 1,
"app/api/insights/coach/nudge-status/__tests__/route.test.ts": 1,
"app/api/insights/comprehensive/__tests__/route.test.ts": 1,
"app/api/insights/correlations/__tests__/route.test.ts": 1,
"app/api/insights/derived/__tests__/route.test.ts": 1,
"app/api/insights/derived/batch/__tests__/route.test.ts": 1,
"app/api/insights/ecg/__tests__/route.test.ts": 1,
"app/api/insights/ecg/[id]/__tests__/route.test.ts": 1,
"app/api/insights/generate/__tests__/route.test.ts": 1,
"app/api/insights/glp1-timeline/__tests__/route.test.ts": 1,
"app/api/insights/layout/__tests__/route.test.ts": 1,
"app/api/insights/metric-status/__tests__/route.test.ts": 1,
"app/api/insights/mood-status/__tests__/route.test.ts": 1,
"app/api/insights/patterns/__tests__/route.test.ts": 1,
"app/api/insights/patterns/[id]/__tests__/route.test.ts": 1,
"app/api/insights/pregenerate/__tests__/route.test.ts": 1,
"app/api/insights/provider-chain/__tests__/put.test.ts": 1,
"app/api/insights/provider-chain/__tests__/route.test.ts": 1,
"app/api/insights/rhythm-events/__tests__/route.test.ts": 1,
"app/api/insights/targets/__tests__/route.test.ts": 1,
"app/api/integrations/fitbit/test/__tests__/route.test.ts": 1,
"app/api/integrations/healthkit/__tests__/route.test.ts": 1,
"app/api/integrations/status/__tests__/route.test.ts": 1,
"app/api/integrations/whoop/test/__tests__/route.test.ts": 1,
"app/api/integrations/withings/test/__tests__/route.test.ts": 1,
"app/api/internal/deploy-webhook/__tests__/route.test.ts": 1,
"app/api/labs/__tests__/error-contract.test.ts": 1,
"app/api/labs/ocr/commit/__tests__/route.test.ts": 1,
"app/api/labs/ocr/extract/__tests__/route.test.ts": 1,
"app/api/labs/restore/__tests__/route.test.ts": 1,
"app/api/mcp/oauth/__tests__/authorize.test.ts": 1,
"app/api/mcp/oauth/__tests__/token.test.ts": 1,
"app/api/mcp/tokens/__tests__/route.test.ts": 1,
"app/api/measurement-reminders/[id]/__tests__/route.test.ts": 1,
"app/api/measurement-reminders/[id]/__tests__/skip-snooze-history.test.ts": 1,
"app/api/measurement-reminders/[id]/complete/__tests__/route.test.ts": 1,
"app/api/measurement-reminders/[id]/satisfy/__tests__/route.test.ts": 1,
"app/api/measurements/__tests__/arrival-wiring.test.ts": 1,
"app/api/measurements/__tests__/group-by-day.test.ts": 1,
"app/api/measurements/__tests__/put-duplicate-timestamp.test.ts": 1,
"app/api/measurements/__tests__/range-aggregation-route.test.ts": 1,
"app/api/measurements/__tests__/zod-multi-issue.test.ts": 1,
"app/api/measurements/[id]/__tests__/route.test.ts": 1,
"app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts": 1,
"app/api/measurements/batch/__tests__/duplicate-prefilter.test.ts": 1,
"app/api/measurements/batch/__tests__/hr-bucket-observability.test.ts": 1,
"app/api/measurements/batch/__tests__/insert-returning-arrivals.test.ts": 1,
"app/api/measurements/batch/__tests__/issue-paths.test.ts": 1,
"app/api/measurements/batch/__tests__/pr-detection-hook.test.ts": 1,
"app/api/measurements/batch/__tests__/refusal-detail.test.ts": 1,
"app/api/measurements/batch/__tests__/sync-trigger.test.ts": 1,
"app/api/measurements/batch/__tests__/timestamp-plausibility.test.ts": 1,
"app/api/measurements/batch/__tests__/unstable-external-id.test.ts": 1,
"app/api/measurements/bulk-delete/__tests__/route.test.ts": 1,
"app/api/measurements/by-external-ids/__tests__/route.test.ts": 1,
"app/api/measurements/restore/__tests__/route.test.ts": 1,
"app/api/measurements/series/__tests__/route.test.ts": 1,
"app/api/measurements/series/__tests__/unknown-query-param-open.test.ts": 1,
"app/api/medications/__tests__/route.test.ts": 1,
"app/api/medications/[id]/__tests__/route.test.ts": 1,
"app/api/medications/[id]/compliance/__tests__/route.test.ts": 1,
"app/api/medications/[id]/dose-history/__tests__/route.test.ts": 1,
"app/api/medications/[id]/glp1/__tests__/route.test.ts": 1,
"app/api/medications/[id]/intake/__tests__/route.test.ts": 1,
"app/api/medications/[id]/intake/__tests__/source-provenance.test.ts": 1,
"app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts": 1,
"app/api/medications/[id]/intake/import/__tests__/route.test.ts": 1,
"app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts": 1,
"app/api/medications/compliance/__tests__/route.test.ts": 1,
"app/api/medications/intake/__tests__/route.test.ts": 1,
"app/api/medications/intake/bulk/__tests__/dose-arrival-scope.test.ts": 1,
"app/api/medications/intake/bulk/__tests__/route.test.ts": 1,
"app/api/medications/intake/dose-history-import/__tests__/route.test.ts": 1,
"app/api/medications/layout/__tests__/route.test.ts": 1,
"app/api/mental-health/assessments/__tests__/route.test.ts": 1,
"app/api/mental-health/assessments/[id]/__tests__/route.test.ts": 1,
"app/api/meta/capabilities/__tests__/route.test.ts": 1,
"app/api/monitoring/glitchtip/__tests__/route.test.ts": 1,
"app/api/mood-entries/__tests__/route.test.ts": 1,
"app/api/mood-entries/[id]/__tests__/route.test.ts": 1,
"app/api/mood-entries/bulk-delete/__tests__/route.test.ts": 1,
"app/api/mood-entries/bulk/__tests__/route.test.ts": 1,
"app/api/mood-entries/restore/__tests__/route.test.ts": 1,
"app/api/mood/analytics/__tests__/route.test.ts": 1,
"app/api/nightscout/connect/__tests__/route.test.ts": 1,
"app/api/notifications/action/[token]/__tests__/route.test.ts": 1,
"app/api/notifications/status/__tests__/route.test.ts": 1,
"app/api/notifications/web-push/__tests__/route.test.ts": 1,
"app/api/notifications/web-push/test/__tests__/route.test.ts": 1,
"app/api/nutrients/__tests__/route.test.ts": 1,
"app/api/nutrients/batch/__tests__/route.test.ts": 1,
"app/api/nutrients/daily/__tests__/route.test.ts": 1,
"app/api/nutrients/water/__tests__/route.test.ts": 1,
"app/api/onboarding/__tests__/error-contract.test.ts": 1,
"app/api/onboarding/complete/__tests__/route.test.ts": 1,
"app/api/oura/callback/__tests__/route.test.ts": 1,
"app/api/oura/credentials/__tests__/route.test.ts": 1,
"app/api/oura/disconnect/__tests__/route.test.ts": 1,
"app/api/oura/status/__tests__/route.test.ts": 1,
"app/api/personal-records/__tests__/route.test.ts": 1,
"app/api/polar/callback/__tests__/route.test.ts": 1,
"app/api/polar/credentials/__tests__/route.test.ts": 1,
"app/api/polar/disconnect/__tests__/route.test.ts": 1,
"app/api/polar/status/__tests__/route.test.ts": 1,
"app/api/search/__tests__/route.test.ts": 1,
"app/api/share-links/__tests__/route.test.ts": 1,
"app/api/sleep/night/__tests__/route.test.ts": 1,
"app/api/sleep/rhythm/__tests__/route.test.ts": 1,
"app/api/strava/callback/__tests__/route.test.ts": 1,
"app/api/sync/changes/__tests__/module-gate.test.ts": 1,
"app/api/sync/state/__tests__/route.test.ts": 1,
"app/api/telegram/webhook/__tests__/route.test.ts": 1,
"app/api/tokens/documents/__tests__/route.test.ts": 1,
"app/api/tokens/measurements/__tests__/route.test.ts": 1,
"app/api/tokens/workouts/__tests__/route.test.ts": 1,
"app/api/user/ai-provider/__tests__/legacy-private-host.test.ts": 1,
"app/api/user/ai-provider/__tests__/route.test.ts": 1,
"app/api/user/avatar/__tests__/route.test.ts": 1,
"app/api/user/avatar/[id]/__tests__/route.test.ts": 1,
"app/api/user/profile/__tests__/route.test.ts": 1,
"app/api/user/thresholds/__tests__/route.test.ts": 1,
"app/api/whoop/callback/__tests__/route.test.ts": 1,
"app/api/whoop/connect/__tests__/route.test.ts": 1,
"app/api/whoop/credentials/__tests__/route.test.ts": 1,
"app/api/whoop/disconnect/__tests__/route.test.ts": 1,
"app/api/whoop/status/__tests__/route.test.ts": 1,
"app/api/whoop/webhook/[token]/__tests__/route.test.ts": 1,
"app/api/withings/callback/__tests__/route.test.ts": 1,
"app/api/withings/connect/__tests__/route.test.ts": 1,
"app/api/withings/credentials/__tests__/route.test.ts": 1,
"app/api/withings/webhook/__tests__/route.test.ts": 1,
"app/api/withings/webhook/[token]/__tests__/route.test.ts": 1,
"app/api/workouts/__tests__/batch-create.test.ts": 1,
"app/api/workouts/__tests__/canonical-dedup.test.ts": 1,
"app/api/workouts/batch/__tests__/unstable-external-id.test.ts": 1
}
}
@@ -0,0 +1,140 @@
{
"$comment": "route tests that stub @/lib/api-handler, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
"count": 133,
"total": 133,
"entries": {
"app/api/account/grants/__tests__/invite-access-level.test.ts": 1,
"app/api/account/grants/__tests__/invite-scope-and-manage.test.ts": 1,
"app/api/activity/__tests__/routes.test.ts": 1,
"app/api/admin/ai-settings/__tests__/route.test.ts": 1,
"app/api/admin/app-logs/__tests__/route.test.ts": 1,
"app/api/admin/audit-log/__tests__/route.test.ts": 1,
"app/api/admin/audit-log/actions/__tests__/route.test.ts": 1,
"app/api/admin/backups/__tests__/route.test.ts": 1,
"app/api/admin/backups/[id]/summary/__tests__/route.test.ts": 1,
"app/api/admin/notifications/diagnostic/__tests__/route.test.ts": 1,
"app/api/admin/notifications/health/__tests__/route.test.ts": 1,
"app/api/admin/notifications/reminder-check/__tests__/route.test.ts": 1,
"app/api/admin/notifications/test/__tests__/route.test.ts": 1,
"app/api/admin/provider-health/__tests__/route.test.ts": 1,
"app/api/admin/settings/__tests__/route.test.ts": 1,
"app/api/admin/settings/web-push-vapid/generate/__tests__/route.test.ts": 1,
"app/api/admin/status/__tests__/route.test.ts": 1,
"app/api/admin/support-report/__tests__/route.test.ts": 1,
"app/api/admin/users/__tests__/route.test.ts": 1,
"app/api/admin/users/[id]/__tests__/route.test.ts": 1,
"app/api/ai/test/__tests__/route.test.ts": 1,
"app/api/anamnesis/facts/__tests__/route.test.ts": 1,
"app/api/auth/me/mfa/disable/__tests__/route.test.ts": 1,
"app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts": 1,
"app/api/auth/mfa/webauthn/verify/__tests__/route.test.ts": 1,
"app/api/auth/native/complete/__tests__/route.test.ts": 1,
"app/api/auth/native/login/__tests__/route.test.ts": 1,
"app/api/auth/native/token/__tests__/route.test.ts": 1,
"app/api/auth/oidc/callback/__tests__/route.test.ts": 1,
"app/api/auth/oidc/login/__tests__/route.test.ts": 1,
"app/api/auth/oidc/native/token/__tests__/route.test.ts": 1,
"app/api/auth/passkeys/__tests__/route.test.ts": 1,
"app/api/auth/passkeys/[id]/__tests__/route.test.ts": 1,
"app/api/auth/password/__tests__/route.test.ts": 1,
"app/api/biomarkers/[id]/__tests__/delete-record-auth.test.ts": 1,
"app/api/coach/about-me/__tests__/route-concurrency.test.ts": 1,
"app/api/coach/about-me/__tests__/route-module-gate.test.ts": 1,
"app/api/coach/about-me/adopt/__tests__/route.test.ts": 1,
"app/api/coach/about-me/questions/__tests__/route-module-gate.test.ts": 1,
"app/api/dawarich/places/__tests__/route.test.ts": 1,
"app/api/documents/inbound/usage/__tests__/route.test.ts": 1,
"app/api/encounters/procedures/__tests__/route.test.ts": 1,
"app/api/environment/backfill/__tests__/route.test.ts": 1,
"app/api/fitbit/connect/__tests__/route.test.ts": 1,
"app/api/fitbit/credentials/__tests__/route.test.ts": 1,
"app/api/fitbit/sync/__tests__/route.test.ts": 1,
"app/api/google-health/credentials/__tests__/route.test.ts": 1,
"app/api/google-health/sync/__tests__/route.test.ts": 1,
"app/api/google-health/sync/status/__tests__/route.test.ts": 1,
"app/api/health/__tests__/route.test.ts": 1,
"app/api/import/__tests__/record-csv-routes.test.ts": 1,
"app/api/import/__tests__/route.test.ts": 1,
"app/api/import/csv/__tests__/route.test.ts": 1,
"app/api/insights/chat/__tests__/golden-transcripts.test.ts": 1,
"app/api/insights/chat/__tests__/route-activity.test.ts": 1,
"app/api/insights/chat/__tests__/route-cancelled-turn.test.ts": 1,
"app/api/insights/chat/__tests__/route-capability-gate.test.ts": 1,
"app/api/insights/chat/__tests__/route-fence-guard.test.ts": 1,
"app/api/insights/chat/__tests__/route-followups.test.ts": 1,
"app/api/insights/chat/__tests__/route-guard-activation.test.ts": 1,
"app/api/insights/chat/__tests__/route-guard-ledger.test.ts": 1,
"app/api/insights/chat/__tests__/route-guard.test.ts": 1,
"app/api/insights/chat/__tests__/route-list-query.test.ts": 1,
"app/api/insights/chat/__tests__/route-snapshot-once.test.ts": 1,
"app/api/insights/chat/__tests__/route-tool-mode.test.ts": 1,
"app/api/insights/chat/__tests__/route-workout-scope.test.ts": 1,
"app/api/insights/chat/[id]/__tests__/route-patch.test.ts": 1,
"app/api/insights/chat/[id]/messages/[messageId]/results/__tests__/route.test.ts": 1,
"app/api/insights/chat/[id]/messages/[messageId]/trail/__tests__/route.test.ts": 1,
"app/api/insights/coach/nudge-status/__tests__/route.test.ts": 1,
"app/api/insights/generate/__tests__/route.test.ts": 1,
"app/api/insights/glp1-timeline/__tests__/route.test.ts": 1,
"app/api/insights/provider-chain/__tests__/put.test.ts": 1,
"app/api/insights/provider-chain/__tests__/route.test.ts": 1,
"app/api/integrations/fitbit/test/__tests__/route.test.ts": 1,
"app/api/integrations/status/__tests__/route.test.ts": 1,
"app/api/integrations/whoop/test/__tests__/route.test.ts": 1,
"app/api/integrations/withings/resume/__tests__/route.test.ts": 1,
"app/api/integrations/withings/test/__tests__/route.test.ts": 1,
"app/api/internal/deploy-webhook/__tests__/route.test.ts": 1,
"app/api/labs/ocr/extract/__tests__/route.test.ts": 1,
"app/api/measurement-reminders/[id]/__tests__/route.test.ts": 1,
"app/api/measurement-reminders/[id]/__tests__/skip-snooze-history.test.ts": 1,
"app/api/measurement-reminders/[id]/complete/__tests__/route.test.ts": 1,
"app/api/measurement-reminders/[id]/satisfy/__tests__/route.test.ts": 1,
"app/api/measurements/__tests__/arrival-wiring.test.ts": 1,
"app/api/medications/extract/__tests__/route.test.ts": 1,
"app/api/medications/intake/bulk/__tests__/dose-arrival-scope.test.ts": 1,
"app/api/monitoring/glitchtip/__tests__/route.test.ts": 1,
"app/api/monitoring/umami-script/__tests__/route.test.ts": 1,
"app/api/monitoring/umami/[...path]/__tests__/route.test.ts": 1,
"app/api/nightscout/connect/__tests__/route.test.ts": 1,
"app/api/nightscout/sync/__tests__/route.test.ts": 1,
"app/api/notifications/web-push/__tests__/route.test.ts": 1,
"app/api/notifications/web-push/test/__tests__/route.test.ts": 1,
"app/api/oura/callback/__tests__/route.test.ts": 1,
"app/api/oura/connect/__tests__/route.test.ts": 1,
"app/api/oura/credentials/__tests__/route.test.ts": 1,
"app/api/oura/disconnect/__tests__/route.test.ts": 1,
"app/api/oura/status/__tests__/route.test.ts": 1,
"app/api/oura/sync/__tests__/route.test.ts": 1,
"app/api/polar/callback/__tests__/route.test.ts": 1,
"app/api/polar/connect/__tests__/route.test.ts": 1,
"app/api/polar/credentials/__tests__/route.test.ts": 1,
"app/api/polar/disconnect/__tests__/route.test.ts": 1,
"app/api/polar/status/__tests__/route.test.ts": 1,
"app/api/polar/sync/__tests__/route.test.ts": 1,
"app/api/record-settings/integrations/[provider]/__tests__/route.test.ts": 1,
"app/api/search/__tests__/route.test.ts": 1,
"app/api/strava/callback/__tests__/route.test.ts": 1,
"app/api/strava/connect/__tests__/route.test.ts": 1,
"app/api/sync/state/__tests__/route.test.ts": 1,
"app/api/telegram/webhook/__tests__/route.test.ts": 1,
"app/api/tokens/documents/__tests__/route.test.ts": 1,
"app/api/tokens/measurements/__tests__/route.test.ts": 1,
"app/api/tokens/workouts/__tests__/route.test.ts": 1,
"app/api/user/ai-provider/__tests__/legacy-private-host.test.ts": 1,
"app/api/user/ai-provider/__tests__/route.test.ts": 1,
"app/api/version/__tests__/route.test.ts": 1,
"app/api/version/check-updates/__tests__/route.test.ts": 1,
"app/api/whoop/callback/__tests__/route.test.ts": 1,
"app/api/whoop/connect/__tests__/route.test.ts": 1,
"app/api/whoop/connect/ticket/__tests__/route.test.ts": 1,
"app/api/whoop/credentials/__tests__/route.test.ts": 1,
"app/api/whoop/disconnect/__tests__/route.test.ts": 1,
"app/api/whoop/status/__tests__/route.test.ts": 1,
"app/api/whoop/sync/__tests__/route.test.ts": 1,
"app/api/whoop/webhook/[token]/__tests__/route.test.ts": 1,
"app/api/withings/callback/__tests__/route.test.ts": 1,
"app/api/withings/connect/__tests__/route.test.ts": 1,
"app/api/withings/sync/__tests__/route.test.ts": 1,
"app/api/withings/webhook/__tests__/route.test.ts": 1,
"app/api/withings/webhook/[token]/__tests__/route.test.ts": 1
}
}
@@ -0,0 +1,130 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 123,
"total": 145428,
"entries": {
"app/api/dashboard/summary/route.ts": 1032,
"app/api/documents/inbound/route.ts": 811,
"app/api/insights/generate/route.ts": 1214,
"app/api/measurements/batch/route.ts": 1562,
"app/api/measurements/route.ts": 938,
"app/api/medications/[id]/route.ts": 1281,
"app/api/medications/intake/bulk/route.ts": 1044,
"app/api/workouts/batch/route.ts": 958,
"app/medications/page-client.tsx": 839,
"app/page-client.tsx": 2324,
"components/admin/backups-section.tsx": 1462,
"components/charts/health-chart.tsx": 2517,
"components/charts/mood-chart.tsx": 1166,
"components/cycle/log-day-sheet.tsx": 1639,
"components/documents/document-detail-sheet.tsx": 1304,
"components/documents/documents-view.tsx": 1223,
"components/insights/coach-panel/chat-bubble.tsx": 888,
"components/insights/coach-panel/coach-conversation.tsx": 1286,
"components/insights/coach-panel/use-coach.ts": 1486,
"components/insights/derived/vitals-dashboard.tsx": 1033,
"components/insights/health-score-card.tsx": 1023,
"components/insights/insights-tab-strip.tsx": 1086,
"components/layout/sidebar-nav.tsx": 855,
"components/measurement-reminders/vorsorge-section.tsx": 1536,
"components/measurements/measurement-form.tsx": 815,
"components/measurements/measurement-list.tsx": 2019,
"components/medications/detail/medication-detail-tabs.tsx": 1160,
"components/medications/dose-history-ledger.tsx": 1052,
"components/medications/medication-table.tsx": 853,
"components/medications/wizard/wizard-payload.ts": 1414,
"components/mood/mood-list.tsx": 1408,
"components/records/life-circumstances-manager.tsx": 1080,
"components/settings/dashboard-layout-section.tsx": 1033,
"components/settings/managed-record-settings-section.tsx": 1071,
"components/timeline/timeline-chart.tsx": 1174,
"components/timeline/timeline-geometry.ts": 1395,
"lib/ai/anthropic-client.ts": 804,
"lib/ai/coach/about-me.ts": 1012,
"lib/ai/coach/coach-prose-grounding.ts": 1079,
"lib/ai/coach/persistence.ts": 1219,
"lib/ai/coach/results/metric-table-tool.ts": 1102,
"lib/ai/coach/snapshot.ts": 1347,
"lib/ai/coach/stream-events.ts": 817,
"lib/ai/coach/tools/executor.ts": 1522,
"lib/ai/coach/turn/model.ts": 820,
"lib/ai/coach/types.ts": 1011,
"lib/ai/codex-client.ts": 1094,
"lib/ai/local-client.ts": 880,
"lib/ai/prompts/insight-generator.ts": 1110,
"lib/ai/provider.ts": 1632,
"lib/analytics/score/reader.ts": 998,
"lib/analytics/sleep-night.ts": 1345,
"lib/analytics/summaries-slice.ts": 1248,
"lib/api-handler.ts": 1978,
"lib/auth/session.ts": 811,
"lib/cache/server-cache.ts": 851,
"lib/cycle/prediction.ts": 883,
"lib/daily/digest.ts": 1317,
"lib/daily/load-digest.ts": 1000,
"lib/daily/today-overview.ts": 838,
"lib/dashboard-layout.ts": 971,
"lib/dashboard/snapshot.ts": 1550,
"lib/day/records.ts": 1174,
"lib/doctor-report/collect.ts": 803,
"lib/export/backup-plan.ts": 853,
"lib/export/coach-backup.ts": 1070,
"lib/export/profile-backup.ts": 1019,
"lib/fitbit/client.ts": 1529,
"lib/fitbit/sync-core.ts": 968,
"lib/gamification/achievements-result.ts": 1094,
"lib/gamification/achievements.ts": 913,
"lib/google-health/client.ts": 1875,
"lib/google-health/mappers.ts": 1295,
"lib/google-health/sync-core.ts": 1634,
"lib/idempotency.ts": 879,
"lib/illness/correlation.ts": 1059,
"lib/import/health-connect/import.ts": 1689,
"lib/import/unzip-export-xml.ts": 825,
"lib/insights/comprehensive-aggregator.ts": 943,
"lib/insights/comprehensive-generate.ts": 1674,
"lib/insights/correlation-channel-series.ts": 1339,
"lib/insights/correlation-discovery.ts": 1240,
"lib/insights/correlations.ts": 1026,
"lib/insights/features.ts": 1923,
"lib/insights/mood-aggregates.ts": 1055,
"lib/insights/mood-status.ts": 814,
"lib/insights/status-cache.ts": 812,
"lib/integrations/status.ts": 1445,
"lib/jobs/apple-health-import-worker.ts": 801,
"lib/jobs/backup-restore.ts": 888,
"lib/jobs/coach-nudge.ts": 1089,
"lib/jobs/insight-pregenerate.ts": 1266,
"lib/jobs/medication-intake-import.ts": 822,
"lib/jobs/offhost-backup.ts": 1168,
"lib/jobs/reaction-line.ts": 824,
"lib/jobs/reminder/medication-reminder-check.ts": 818,
"lib/jobs/reminder/register-integration-sync.ts": 1051,
"lib/jobs/reminder/register-maintenance.ts": 2197,
"lib/jobs/reminder/register-rollup.ts": 1156,
"lib/jobs/reminder/register-status.ts": 878,
"lib/managed-profiles/handover.ts": 986,
"lib/mcp/record-search.ts": 1467,
"lib/mcp/rich-reads.ts": 1467,
"lib/mcp/tools.ts": 2654,
"lib/measurements/dense-intraday-retention.ts": 1057,
"lib/measurements/import-apple-health-export.ts": 1722,
"lib/medications/scheduling/cadence.ts": 978,
"lib/medications/scheduling/recurrence.ts": 1108,
"lib/medications/scheduling/slot-upsert.ts": 926,
"lib/notifications/senders/apns.ts": 1100,
"lib/oura/client.ts": 1360,
"lib/oura/sync.ts": 902,
"lib/polar/client.ts": 1010,
"lib/rollups/measurement-rollups.ts": 1496,
"lib/search/search-records.ts": 996,
"lib/sharing/grants.ts": 1046,
"lib/support/report.ts": 905,
"lib/telegram-webhook-handlers.ts": 1341,
"lib/validations/inbound-documents.ts": 1130,
"lib/whoop/client.ts": 917,
"lib/withings/sync-sleep.ts": 884,
"lib/withings/sync.ts": 978,
"proxy.ts": 870
}
}
@@ -0,0 +1,418 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 411,
"total": 148109,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
"app/api/admin/notifications/reminder-check/route.ts :: apiHandler(arg)": 208,
"app/api/admin/settings/route.ts :: apiHandler(arg) #2": 217,
"app/api/analytics/route.ts :: buildAnalyticsResponse": 446,
"app/api/auth/login/route.ts :: apiHandler(arg)": 262,
"app/api/auth/me/route.ts :: apiHandler(arg)": 386,
"app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 499,
"app/api/auth/register/route.ts :: apiHandler(arg)": 275,
"app/api/coach/about-me/route.ts :: apiHandler(arg) #2": 309,
"app/api/cycle/calendar/route.ts :: apiHandler(arg)": 230,
"app/api/cycle/day-logs/bulk/route.ts :: postBulk": 213,
"app/api/cycle/insights/route.ts :: apiHandler(arg)": 226,
"app/api/dashboard/summary/route.ts :: buildDashboardSummary": 606,
"app/api/devices/route.ts :: apiHandler(arg)": 237,
"app/api/documents/inbound/bulk/route.ts :: apiHandler(arg)": 212,
"app/api/documents/inbound/route.ts :: processUpload": 309,
"app/api/encounters/[id]/route.ts :: apiHandler(arg) #2": 275,
"app/api/environment/route.ts :: apiHandler(arg)": 201,
"app/api/export/health-record/route.ts :: apiHandler(arg)": 246,
"app/api/import/csv/route.ts :: apiHandler(arg)": 407,
"app/api/import/route.ts :: apiHandler(arg)": 364,
"app/api/ingest/medication/route.ts :: apiHandler(arg)": 269,
"app/api/insights/cards/route.ts :: apiHandler(arg)": 211,
"app/api/insights/comprehensive/route.ts :: buildComprehensiveResponse": 505,
"app/api/insights/generate/route.ts :: apiHandler(arg) #2": 866,
"app/api/insights/generate/route.ts :: apiHandler(arg) > withFeatureCacheScope(arg)": 862,
"app/api/integrations/status/route.ts :: apiHandler(arg)": 451,
"app/api/labs/[id]/route.ts :: apiHandler(arg) #2": 244,
"app/api/labs/ocr/commit/route.ts :: commitOcrRows": 290,
"app/api/labs/route.ts :: postLabResult": 248,
"app/api/mcp/oauth/token/route.ts :: POST": 250,
"app/api/mcp/oauth/token/route.ts :: POST > withBackgroundEvent(arg)": 248,
"app/api/measurement-reminders/[id]/route.ts :: apiHandler(arg) #2": 240,
"app/api/measurements/[id]/route.ts :: apiHandler(arg) #2": 203,
"app/api/measurements/batch/route.ts :: postBatch": 1255,
"app/api/measurements/route.ts :: apiHandler(arg)": 286,
"app/api/measurements/route.ts :: postMeasurement": 377,
"app/api/measurements/series/route.ts :: apiHandler(arg)": 549,
"app/api/medications/[id]/dose-history/route.ts :: apiHandler(arg)": 256,
"app/api/medications/[id]/intake/[eventId]/route.ts :: apiHandler(arg)": 390,
"app/api/medications/[id]/intake/route.ts :: postIntake": 607,
"app/api/medications/[id]/inventory/[itemId]/route.ts :: apiHandler(arg)": 210,
"app/api/medications/[id]/route.ts :: apiHandler(arg)": 214,
"app/api/medications/[id]/route.ts :: apiHandler(arg) #2": 892,
"app/api/medications/[id]/route.ts :: apiHandler(arg) > prisma.$transaction(arg)": 293,
"app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts :: apiHandler(arg)": 254,
"app/api/medications/extract/route.ts :: handleExtract": 203,
"app/api/medications/intake/bulk/route.ts :: postBulk": 846,
"app/api/medications/intake/route.ts :: apiHandler(arg) #2": 432,
"app/api/medications/route.ts :: postMedication": 403,
"app/api/mental-health/assessments/route.ts :: postAssessment": 267,
"app/api/mood-entries/[id]/route.ts :: apiHandler(arg) #2": 302,
"app/api/mood-entries/bulk/route.ts :: postBulk": 479,
"app/api/mood-entries/route.ts :: postMoodEntry": 329,
"app/api/nutrients/batch/route.ts :: postBatch": 304,
"app/api/onboarding/complete/route.ts :: completeNeedsFlow": 218,
"app/api/record-settings/[family]/route.ts :: apiHandler(arg) #2": 262,
"app/api/sync/changes/route.ts :: apiHandler(arg)": 427,
"app/api/user/ai-provider/route.ts :: apiHandler(arg) #2": 252,
"app/api/withings/callback/route.ts :: apiHandler(arg)": 247,
"app/api/workouts/[id]/route.ts :: apiHandler(arg)": 297,
"app/api/workouts/batch/route.ts :: postBatch": 709,
"app/auth/login/page.tsx :: LoginPage": 576,
"app/auth/register/page.tsx :: RegisterPage": 285,
"app/coach/conversations/page.tsx :: CoachConversationsBody": 249,
"app/coach/plans/page.tsx :: CoachPlansBody": 216,
"app/insights/catalog/page.tsx :: MetricCatalogPage": 204,
"app/insights/medications/page.tsx :: InsightsMedikamentePage": 265,
"app/insights/page-client.tsx :: InsightsPageClient": 376,
"app/labs/page.tsx :: LabsPage": 228,
"app/medications/page-client.tsx :: MedicationsPageClient": 590,
"app/notifications/page.tsx :: NotificationsPage": 327,
"app/page-client.tsx :: DashboardPageClient": 2131,
"app/page-client.tsx :: DashboardPageClient > (anonymous)": 1279,
"components/activity/activity-calendar-card.tsx :: ActivityCalendarCard": 470,
"components/admin/admin-shell.tsx :: AdminShell": 254,
"components/admin/api-token-overview-section.tsx :: ApiTokenOverviewSection": 212,
"components/admin/app-log-preview-section.tsx :: AppLogPreviewSection": 341,
"components/admin/backups-section.tsx :: BackupsSection": 618,
"components/admin/backups-section.tsx :: RestoreRowDialog": 226,
"components/admin/central-codex-section.tsx :: CentralCodexSection": 241,
"components/admin/encryption-section.tsx :: EncryptionSection": 303,
"components/admin/general-settings-section.tsx :: GeneralSettingsSection": 220,
"components/admin/glitchtip-section.tsx :: GlitchtipSection": 233,
"components/admin/host-metrics-chart.tsx :: HostMetricsChart": 232,
"components/admin/invite-tokens-section.tsx :: InviteTokensSection": 525,
"components/admin/key-backup-card.tsx :: KeyBackupCard": 238,
"components/admin/login-overview-section.tsx :: LoginOverviewSection": 503,
"components/admin/pulse-day-mean-card.tsx :: PulseDayMeanCard": 305,
"components/admin/reminders-section.tsx :: RemindersSection": 309,
"components/admin/user-management-section.tsx :: UserManagementSection": 626,
"components/admin/web-push-vapid-section.tsx :: WebPushVapidSection": 217,
"components/auth/claim-profile.tsx :: ClaimProfile": 429,
"components/auth/mfa-login-step.tsx :: MfaLoginStep": 217,
"components/charts/compliance-heatmap.tsx :: ComplianceHeatmap": 325,
"components/charts/health-chart.tsx :: HealthChart": 1929,
"components/charts/medication-compliance-chart.tsx :: MedicationComplianceChart": 543,
"components/charts/mood-chart.tsx :: MoodChart": 896,
"components/charts/mood-heatmap.tsx :: MoodHeatmap": 296,
"components/charts/trend-card.tsx :: TrendCard": 365,
"components/clinician/clinician-view.tsx :: ClinicianView": 223,
"components/command-palette/command-palette.tsx :: PaletteBody": 333,
"components/custom-metrics/custom-metric-chart.tsx :: CustomMetricChart": 231,
"components/custom-metrics/custom-metric-detail.tsx :: CustomMetricDetail": 277,
"components/custom-metrics/custom-metric-form.tsx :: CustomMetricForm": 214,
"components/custom-metrics/custom-metric-history-list.tsx :: CustomMetricHistoryList": 302,
"components/cycle/bbt-chart.tsx :: BbtChart": 244,
"components/cycle/cycle-calendar.tsx :: CycleCalendar": 292,
"components/cycle/cycle-settings.tsx :: CycleSettings": 228,
"components/cycle/cycle-view.tsx :: CycleView": 408,
"components/cycle/log-day-sheet.tsx :: LogDaySheet": 753,
"components/daily/today-hero.tsx :: TodayHero": 488,
"components/dashboard/dashboard-header.tsx :: DashboardHeader": 215,
"components/dashboard/medication-intake-quick-add.tsx :: MedicationIntakeQuickAdd": 384,
"components/day/day-layer.tsx :: DayLayer": 358,
"components/day/day-view.tsx :: DayView": 309,
"components/documents/document-ai-section.tsx :: DocumentAiSection": 262,
"components/documents/document-bulk-bar.tsx :: DocumentBulkBar": 204,
"components/documents/document-card.tsx :: DocumentCard": 354,
"components/documents/document-detail-sheet.tsx :: DocumentDetailSheet": 935,
"components/documents/document-facts-review.tsx :: DocumentFactsSection": 252,
"components/documents/document-facts-review.tsx :: FactRow": 262,
"components/documents/document-filter-bar.tsx :: DocumentFilterBar": 348,
"components/documents/document-record-links.tsx :: DocumentRecordLinks": 202,
"components/documents/document-timeline.tsx :: DocumentTimeline": 314,
"components/documents/documents-view.tsx :: DocumentsView": 969,
"components/documents/sources/document-source-picker.tsx :: DocumentSourcePicker": 565,
"components/illness/log-day-sheet.tsx :: LogDaySheet": 265,
"components/illness/new-episode-sheet.tsx :: NewEpisodeSheet": 264,
"components/insights/coach-panel/attachment-picker.tsx :: AttachmentPicker": 208,
"components/insights/coach-panel/chat-bubble.tsx :: ChatBubbleImpl": 419,
"components/insights/coach-panel/coach-conversation.tsx :: CoachConversation": 1011,
"components/insights/coach-panel/coach-input.tsx :: CoachInput": 259,
"components/insights/coach-panel/coach-model-picker.tsx :: CoachModelPicker": 212,
"components/insights/coach-panel/conversations-panel.tsx :: ConversationsPanel": 317,
"components/insights/coach-panel/message-thread.tsx :: MessageThread": 390,
"components/insights/coach-panel/sources-rail.tsx :: SourcesRail": 203,
"components/insights/coach-panel/use-coach.ts :: useSendCoachMessage": 436,
"components/insights/coach-panel/use-coach.ts :: useSendCoachMessage > useCallback(arg) #2": 340,
"components/insights/daily-briefing.tsx :: DailyBriefing": 224,
"components/insights/derived/coach-read-strip.tsx :: CoachReadStrip": 202,
"components/insights/derived/composite-score-anatomy.tsx :: CompositeScoreAnatomy": 251,
"components/insights/derived/score-trend-chart.tsx :: ScoreTrendChart": 255,
"components/insights/derived/vitals-dashboard.tsx :: VitalsDashboard": 239,
"components/insights/derived/wellness-scores.tsx :: WellnessScores": 273,
"components/insights/glucose/glucose-clinical-panel.tsx :: GlucoseClinicalPanel": 213,
"components/insights/health-score-card.tsx :: HealthScoreCard": 561,
"components/insights/healthkit-metric-page.tsx :: HealthKitMetricPage": 322,
"components/insights/insight-status-card.tsx :: InsightStatusCard": 205,
"components/insights/insights-edit-mode.tsx :: InsightsEditMode": 270,
"components/insights/insights-tab-strip.tsx :: InsightsTabStripImpl": 410,
"components/insights/intraday-pulse-chart.tsx :: IntradayPulseChart": 283,
"components/insights/layout-coach-fab.tsx :: LayoutCoachFab": 230,
"components/insights/metric-target-summary.tsx :: TargetReferencePanel": 223,
"components/insights/mood/mood-dimension-trends.tsx :: MoodDimensionTrends": 227,
"components/insights/sleep-stage-stacked-bar.tsx :: SleepStageStackedBar": 354,
"components/insights/sub-page-shell.tsx :: SubPageShell": 266,
"components/insights/trends-row.tsx :: TrendsRow": 215,
"components/labs/biomarker-form.tsx :: BiomarkerForm": 244,
"components/labs/biomarker-manager.tsx :: BiomarkerManager": 242,
"components/labs/biomarker-merge-sheet.tsx :: BiomarkerMergeSheet": 250,
"components/labs/lab-biomarker-chart.tsx :: LabBiomarkerChart": 370,
"components/labs/lab-biomarker-detail.tsx :: LabBiomarkerDetail": 481,
"components/labs/lab-form.tsx :: LabForm": 473,
"components/labs/lab-history-list.tsx :: LabHistoryList": 534,
"components/labs/lab-list.tsx :: LabList": 308,
"components/labs/ocr-review-dialog.tsx :: OcrReviewDialog": 359,
"components/labs/ocr-row-editor.tsx :: OcrRowEditor": 393,
"components/layout/auth-shell.tsx :: AuthShell": 520,
"components/layout/bottom-nav.tsx :: BottomNav": 262,
"components/layout/capture-picker.tsx :: CapturePicker": 235,
"components/layout/sidebar-nav.tsx :: SidebarNav": 372,
"components/layout/top-bar.tsx :: TopBar": 212,
"components/links/entity-link-picker.tsx :: EntityLinkPicker": 392,
"components/measurement-reminders/vorsorge-dashboard-card.tsx :: VorsorgeDashboardCard": 215,
"components/measurement-reminders/vorsorge-section.tsx :: VorsorgeCard": 635,
"components/measurement-reminders/vorsorge-section.tsx :: VorsorgeSection": 618,
"components/measurements/measurement-form.tsx :: MeasurementForm": 500,
"components/measurements/measurement-list.tsx :: MeasurementList": 1586,
"components/measurements/measurement-list.tsx :: MeasurementList > data.measurements.map(arg)": 223,
"components/measurements/measurement-list.tsx :: MeasurementList > data.measurements.map(arg) #2": 229,
"components/medications/detail/efficacy/efficacy-chart.tsx :: EfficacyChart": 361,
"components/medications/detail/medication-detail-tabs.tsx :: MedicationDetailTabs": 771,
"components/medications/dose-history-add-dialog.tsx :: LedgerAddDialog": 223,
"components/medications/dose-history-ledger.tsx :: DoseHistoryLedger": 404,
"components/medications/dose-history-ledger.tsx :: LedgerRowItem": 314,
"components/medications/dose-strength-curve.tsx :: DoseStrengthCurve": 266,
"components/medications/glp1-medication-card.tsx :: Glp1MedicationCard": 405,
"components/medications/injection-site-picker.tsx :: InjectionSitePicker": 287,
"components/medications/intake-history-editable.tsx :: IntakeHistoryEditable": 224,
"components/medications/intake-history-list-v2.tsx :: IntakeHistoryListV2": 427,
"components/medications/intake-import-dialog.tsx :: IntakeImportDialog": 208,
"components/medications/log-intake-dialog.tsx :: LogIntakeDialog": 267,
"components/medications/medication-card.tsx :: MedicationCard": 413,
"components/medications/medication-table.tsx :: MedicationTableRowItem": 377,
"components/medications/scheduling/natural-language-extractor.tsx :: NaturalLanguageExtractor": 214,
"components/medications/scheduling/schedule-history-timeline.tsx :: EraDialog": 219,
"components/medications/scheduling/schedule-history-timeline.tsx :: ScheduleHistoryTimeline": 256,
"components/medications/sections/api-tokens-row.tsx :: ApiTokensRow": 296,
"components/medications/sections/composition-section.tsx :: CompositionEditor": 362,
"components/medications/sections/inventory-dialogs.tsx :: AddInventoryDialog": 238,
"components/medications/sections/inventory-section.tsx :: InventorySection": 368,
"components/medications/side-effects-section.tsx :: SideEffectsSection": 516,
"components/medications/wizard/medication-wizard-dialog.tsx :: WizardDialogShell": 516,
"components/medications/wizard/steps/step3-dose.tsx :: Step3Dose": 222,
"components/medications/wizard/steps/step8-summary.tsx :: Step8Summary": 230,
"components/mental-health/check-in-wizard.tsx :: CheckInWizard": 205,
"components/mental-health/mental-wellbeing.tsx :: MentalWellbeing": 209,
"components/mood/manage/tag-groups-card.tsx :: TagGroupsCard": 256,
"components/mood/manage/tag-manager-card.tsx :: TagManagerCard": 270,
"components/mood/mood-form.tsx :: MoodForm": 513,
"components/mood/mood-list.tsx :: MoodList": 1109,
"components/mood/mood-tag-picker.tsx :: MoodTagPicker": 257,
"components/onboarding/confirm-screen.tsx :: ConfirmScreen": 255,
"components/onboarding/done-screen.tsx :: DoneScreen": 408,
"components/onboarding/getting-started-checklist.tsx :: GettingStartedChecklist": 425,
"components/onboarding/tour.tsx :: OnboardingTour": 544,
"components/practitioners/practitioner-list.tsx :: PractitionerList": 230,
"components/provenance/source-provenance-view.tsx :: SourceProvenanceView": 363,
"components/records/allergy-form.tsx :: AllergyForm": 225,
"components/records/audit-c-panel.tsx :: AuditCPanel": 285,
"components/records/free-text-take-over.tsx :: FreeTextTakeOver": 201,
"components/records/life-circumstances-manager.tsx :: SectionBody": 482,
"components/settings/about-section.tsx :: AboutSection": 236,
"components/settings/access/grant-invite-card.tsx :: GrantInviteCard": 311,
"components/settings/access/managed-profile-create-form.tsx :: ManagedProfileCreateForm": 235,
"components/settings/access/managed-profile-edit-form.tsx :: ManagedProfileEditFields": 208,
"components/settings/access/managed-profile-guardians.tsx :: ManagedProfileGuardians": 234,
"components/settings/account-section/index.tsx :: AccountSection": 659,
"components/settings/ai/codex-provider-form.tsx :: CodexProviderForm": 280,
"components/settings/ai/fallback-chain-card.tsx :: FallbackChainCard": 228,
"components/settings/ai/runtime-actions-row.tsx :: RuntimeActionsRow": 282,
"components/settings/api-section.tsx :: ApiTokensCard": 366,
"components/settings/coach-nudge-card.tsx :: CoachNudgeCard": 251,
"components/settings/coach-prefs-section.tsx :: CoachPrefsSection": 350,
"components/settings/dashboard-layout-section.tsx :: DashboardLayoutSection": 620,
"components/settings/environment-section.tsx :: EnvironmentSection": 666,
"components/settings/import-panel/apple-health-import-card.tsx :: AppleHealthImportCard": 298,
"components/settings/import-panel/csv-import-card.tsx :: CsvImportCard": 212,
"components/settings/import-panel/dose-history-import-card.tsx :: DoseHistoryImportCard": 225,
"components/settings/import-panel/health-connect-import-card.tsx :: HealthConnectImportCard": 293,
"components/settings/import-panel/json-import-card.tsx :: JsonImportCard": 223,
"components/settings/import-panel/record-csv-import-card.tsx :: RecordCsvImportSheet": 460,
"components/settings/insights-pill-order-section.tsx :: InsightsPillOrderSection": 255,
"components/settings/integrations/connections-panel.tsx :: ConnectionsPanel": 230,
"components/settings/integrations/document-sources-card.tsx :: SourceForm": 306,
"components/settings/integrations/google-health-card.tsx :: GoogleHealthCard": 300,
"components/settings/integrations/integration-card.tsx :: IntegrationCard": 209,
"components/settings/integrations/oauth-integration-card.tsx :: OAuthIntegrationCardView": 321,
"components/settings/low-stock-card.tsx :: LowStockCard": 296,
"components/settings/mcp-section.tsx :: McpTokensCard": 257,
"components/settings/mood-reminder-card.tsx :: MoodReminderCard": 234,
"components/settings/report-selection/selection-panel.tsx :: HealthRecordExportPanel": 370,
"components/settings/score-section.tsx :: ScoreSection": 383,
"components/settings/security-section/passkey-list-section.tsx :: PasskeyListSection": 410,
"components/settings/security-section/security-keys-card.tsx :: SecurityKeysCard": 288,
"components/settings/security-section/totp-card.tsx :: TotpCard": 533,
"components/settings/security-sessions-card.tsx :: SecuritySessionsCard": 245,
"components/settings/settings-shell.tsx :: SettingsShell": 254,
"components/settings/share-link-create-form.tsx :: ShareLinkCreateForm": 451,
"components/settings/sharing-section.tsx :: ShareLinksCard": 260,
"components/settings/sources-section.tsx :: SourcesSection": 601,
"components/settings/thresholds-editor-section.tsx :: MetricRow": 241,
"components/settings/web-push-card.tsx :: WebPushCard": 215,
"components/symptoms/symptom-entry-form.tsx :: SymptomEntryForm": 317,
"components/symptoms/symptoms-section.tsx :: SymptomsSection": 205,
"components/targets/range-bar.tsx :: RangeBar": 220,
"components/targets/target-edit-sheet.tsx :: TargetEditSheetBody": 474,
"components/timeline/life-event-form.tsx :: LifeEventForm": 281,
"components/timeline/series-point-tips.tsx :: SeriesPointTips": 212,
"components/timeline/timeline-chart.tsx :: ChartBody": 243,
"components/timeline/timeline-chronicle.tsx :: TimelineChronicle": 274,
"components/timeline/timeline-view.tsx :: TimelineView": 501,
"components/ui/responsive-sheet.tsx :: ResponsiveSheet": 239,
"components/vaccinations/catalog-picker.tsx :: CatalogPicker": 221,
"components/vaccinations/custom-vaccine-sheet.tsx :: CustomVaccineSheet": 246,
"components/vaccinations/vaccination-form.tsx :: VaccinationForm": 218,
"components/workouts/manual-workout-form.tsx :: ManualWorkoutForm": 323,
"hooks/use-sheet-drag.ts :: createSheetDrag": 312,
"lib/activity/calendar-math.ts :: buildActivityCalendar": 204,
"lib/ai/anthropic-client.ts :: generateCompletion": 294,
"lib/ai/coach/cycle-snapshot.ts :: buildCycleSnapshotBlock": 218,
"lib/ai/coach/glp1-snapshot.ts :: buildGlp1SnapshotBlock": 218,
"lib/ai/coach/snapshot.ts :: buildCoachSnapshotImpl": 882,
"lib/ai/coach/tools/loop.ts :: runCoachToolLoop": 438,
"lib/ai/coach/turn/model.ts :: runTurnModel": 560,
"lib/ai/coach/turn/pipeline.ts :: runTurn": 274,
"lib/ai/coach/turn/reply-guards.ts :: guardReply": 331,
"lib/ai/codex-client.ts :: parseResponse": 302,
"lib/ai/local-client.ts :: generateCompletionStream": 298,
"lib/ai/openai-client.ts :: generateCompletion": 390,
"lib/analytics/bp-in-target-fast-path.ts :: computeFromRollups": 203,
"lib/analytics/compliance/display.ts :: calculateCompliance": 212,
"lib/analytics/correlations-fast-path.ts :: computeCorrelationHypothesesFastPath": 256,
"lib/analytics/intraday-pulse-io.ts :: loadIntradayPulse": 206,
"lib/analytics/score/reader.ts :: computeUserHealthScore": 324,
"lib/analytics/summaries-slice.ts :: computeFromRollups": 377,
"lib/api-handler.ts :: apiHandler": 207,
"lib/api-handler.ts :: apiHandler > wrapped": 201,
"lib/auth/profile-update.ts :: applyProfileUpdate": 353,
"lib/cache/server-cache.ts :: createServerCaches": 230,
"lib/cycle/backup.ts :: restoreCycleData": 224,
"lib/cycle/day-log-write.ts :: writeDayLog": 311,
"lib/daily/digest.ts :: buildDailyDigest": 227,
"lib/daily/load-digest.ts :: loadDailyDigest": 452,
"lib/dashboard/snapshot.ts :: buildDashboardSnapshot": 313,
"lib/dashboard/snapshot.ts :: buildExtras": 240,
"lib/day/records.ts :: medicationsPart": 306,
"lib/doctor-report-pdf/clinical-records-notes-section.ts :: buildClinicalRecordsNotesSection": 648,
"lib/doctor-report-pdf/measurements-charts-section.ts :: buildMeasurementsChartsSection": 398,
"lib/doctor-report-pdf/medication-mood-wellness-section.ts :: buildMedicationMoodWellnessSection": 509,
"lib/doctor-report/collect.ts :: collectDoctorReportData": 678,
"lib/doctor-report/dense-buckets.ts :: loadDenseMeasurementBuckets": 210,
"lib/documents/fenced-chat.ts :: streamFencedReply": 309,
"lib/documents/ingest.ts :: ingestDocument": 272,
"lib/export/backup-json-scan.ts :: scanBackupJson": 225,
"lib/export/profile-backup.ts :: buildProfileBackupSection": 270,
"lib/export/profile-backup.ts :: restoreProfileData": 291,
"lib/export/vaccinations-backup.ts :: restoreVaccinationsData": 216,
"lib/export/visits-backup.ts :: restoreVisitsData": 261,
"lib/fhir/build-bundle.ts :: buildFhirDocumentBundle": 363,
"lib/fitbit/sync-core.ts :: upsertFitbitMeasurements": 370,
"lib/gamification/achievements-result.ts :: buildAchievementsResult": 586,
"lib/google-health/client.ts :: forEachDataPointPage": 288,
"lib/google-health/client.ts :: forEachDataPointPage > walk": 223,
"lib/google-health/sync-core.ts :: upsertGoogleHealthMeasurements": 463,
"lib/google-health/sync.ts :: runGoogleHealthSyncCycle": 203,
"lib/illness/correlation.ts :: computeIllnessCorrelation": 233,
"lib/import/labs-csv.ts :: importLabsCsv": 309,
"lib/import/labs-csv.ts :: importLabsCsv > flush": 231,
"lib/insights/biomarker-status.ts :: generateBiomarkerStatus": 290,
"lib/insights/blood-pressure-status.ts :: prepareBloodPressureStatusForUser": 635,
"lib/insights/bmi-status.ts :: prepareBmiStatusForUser": 387,
"lib/insights/comprehensive-aggregator.ts :: buildFromRollups": 220,
"lib/insights/comprehensive-generate.ts :: generateComprehensiveInsight": 876,
"lib/insights/features.ts :: extractFeatures": 796,
"lib/insights/general-status.ts :: prepareGeneralStatusForUser": 449,
"lib/insights/medication-compliance-status.ts :: prepareMedicationComplianceStatusForUser": 485,
"lib/insights/metric-status.ts :: generateMetricStatus": 477,
"lib/insights/mood-aggregates.ts :: fetchMoodAggregates": 246,
"lib/insights/mood-status.ts :: prepareMoodStatusForUser": 646,
"lib/insights/pulse-status.ts :: preparePulseStatusForUser": 414,
"lib/insights/status-provider.ts :: runStatusCompletion": 241,
"lib/insights/weight-status.ts :: prepareWeightStatusForUser": 598,
"lib/integrations/connection-health-load.ts :: loadConnectionFacts": 227,
"lib/integrations/status.ts :: recordSyncFailure": 292,
"lib/jobs/apple-health-import-worker.ts :: handleAppleHealthImport": 300,
"lib/jobs/coach-nudge.ts :: findTriggerForUser": 279,
"lib/jobs/coach-nudge.ts :: runCoachNudgeTick": 292,
"lib/jobs/coach-reminder-sweep.ts :: runCoachReminderSweep": 222,
"lib/jobs/cycle-reminder.ts :: runCycleReminderTick": 243,
"lib/jobs/document-summary.ts :: runDocumentSummaryJob": 239,
"lib/jobs/google-health-backfill.ts :: runGoogleHealthBackfillForUser": 276,
"lib/jobs/insight-pregenerate.ts :: forceWarmUser": 282,
"lib/jobs/insight-pregenerate.ts :: forceWarmUser > withFeatureCacheScope(arg)": 252,
"lib/jobs/insight-pregenerate.ts :: runInsightPregenerate": 383,
"lib/jobs/insight-pregenerate.ts :: runInsightPregenerate > pregenerateCandidate": 233,
"lib/jobs/measurement-reminder.ts :: runMeasurementReminderTick": 286,
"lib/jobs/medication-intake-import.ts :: processNextChunk": 220,
"lib/jobs/medication-intake-import.ts :: processNextChunk > client.$transaction(arg)": 212,
"lib/jobs/medication-low-stock.ts :: runMedicationLowStockTick": 286,
"lib/jobs/oura-backfill.ts :: runOuraBackfillForUser": 236,
"lib/jobs/reaction-line.ts :: runReactionLine": 337,
"lib/jobs/reminder/medication-reminder-check.ts :: handleReminderCheck": 619,
"lib/jobs/reminder/medication-reminder-check.ts :: handleReminderCheck > withBackgroundEvent(arg)": 614,
"lib/jobs/reminder/register-integration-sync.ts :: enqueueIntegrationSyncBootDiscovery": 224,
"lib/jobs/reminder/register-integration-sync.ts :: registerIntegrationSyncQueues": 320,
"lib/jobs/reminder/register-maintenance.ts :: enqueueMaintenanceBootDiscovery": 232,
"lib/jobs/reminder/register-maintenance.ts :: registerMaintenanceQueues": 997,
"lib/jobs/reminder/register-rollup.ts :: enqueueRollupBootDiscovery": 238,
"lib/jobs/reminder/register-rollup.ts :: registerRollupQueues": 616,
"lib/jobs/reminder/register-status.ts :: registerStatusQueues": 542,
"lib/jobs/workout-insight-generate.ts :: runWorkoutInsightGenerate": 324,
"lib/labs/biomarker-merge.ts :: runMerge": 254,
"lib/labs/ocr-extract.ts :: runOcrExtraction": 207,
"lib/labs/ocr-run.ts :: executeOcrExtraction": 206,
"lib/mcp/tools.ts :: searchAndFetchTools": 421,
"lib/mcp/tools.ts :: searchAndFetchTools > run": 205,
"lib/measurements/consolidate-daily-mean.ts :: consolidateDailyMean": 278,
"lib/measurements/consolidate-legacy-steps.ts :: consolidateLegacySteps": 263,
"lib/measurements/dense-intraday-retention.ts :: runDenseIntradayRetention": 537,
"lib/measurements/dense-intraday-retention.ts :: runDenseIntradayRetention > walkSource": 447,
"lib/measurements/dense-intraday-retention.ts :: runDenseIntradayRetention > walkSource > writeDay": 373,
"lib/measurements/dense-intraday-retention.ts :: runDenseIntradayRetention > walkSource > writeDay > foldOnce": 212,
"lib/measurements/drain-per-sample-cumulative.ts :: drainPerSampleCumulative": 344,
"lib/measurements/drain-per-sample-cumulative.ts :: drainPerSampleCumulative > writeDay": 205,
"lib/measurements/import-apple-health-export.ts :: streamParseExportXml": 1245,
"lib/measurements/import-apple-health-export.ts :: streamParseExportXml > (anonymous) #2": 323,
"lib/medications/efficacy/build-efficacy.ts :: buildMedicationEfficacy": 268,
"lib/medications/intake-slot-dedup.ts :: dedupeUserIntakeSlots": 321,
"lib/medications/list-read.ts :: buildMedicationsList": 310,
"lib/medications/scheduling/dose-history.ts :: reconstructDoseHistory": 218,
"lib/multipart/stream-to-disk.ts :: streamMultipartToDisk": 272,
"lib/notifications/dispatcher.ts :: dispatchNotification": 378,
"lib/notifications/senders/apns.ts :: sendViaApns": 299,
"lib/notifications/senders/web-push.ts :: sendViaWebPush": 268,
"lib/personal-records/pr-detection-worker.ts :: detectPersonalRecordsForUser": 239,
"lib/personal-records/pr-direction.ts :: getPRDirection": 202,
"lib/record-activity/activity-verb.ts :: recordActivityVerbLine": 395,
"lib/strava/sync.ts :: runStravaSync": 219,
"lib/support/report.ts :: collectSupportReportFacts": 332,
"lib/targets/build-response.ts :: buildTargetsResponse": 330,
"lib/targets/vitals-builder.ts :: buildVitalTargets": 324,
"lib/telegram-webhook-handlers.ts :: handleCallback": 599,
"lib/telegram-webhook-handlers.ts :: handleTextMessage": 337,
"lib/withings/sync-activity.ts :: syncUserActivity": 377,
"lib/withings/sync-ecg.ts :: syncUserEcg": 202,
"lib/withings/sync-sleep.ts :: syncUserSleep": 414,
"lib/withings/sync.ts :: syncUserMeasurements": 373,
"proxy.ts :: proxy": 499
}
}
+531
View File
@@ -0,0 +1,531 @@
{
"$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
"count": 524,
"total": 5495,
"entries": {
"__tests__/api/auth/login/mfa-branch.test.ts": 10,
"__tests__/api/auth/mfa/verify.test.ts": 9,
"__tests__/api/auth/registration-status/route.test.ts": 4,
"__tests__/derived-wellness-value-contract.test.ts": 2,
"__tests__/notification-delivery-identity.test.ts": 13,
"__tests__/profile-email-rate-limit-contract.test.ts": 3,
"__tests__/record-write-rate-limit-contract.test.ts": 3,
"__tests__/shared-record-navigation.test.tsx": 2,
"__tests__/sharing-legacy-client-contract.test.ts": 2,
"__tests__/tz-day-paths-harness.test.ts": 12,
"app/api/account/grants/__tests__/invite-access-level.test.ts": 2,
"app/api/account/grants/__tests__/invite-recent-proof.test.ts": 4,
"app/api/account/grants/__tests__/invite-scope-and-manage.test.ts": 2,
"app/api/admin/ai-settings/__tests__/route.test.ts": 9,
"app/api/admin/app-logs/__tests__/route.test.ts": 1,
"app/api/admin/audit-log/__tests__/route.test.ts": 1,
"app/api/admin/audit-log/actions/__tests__/route.test.ts": 2,
"app/api/admin/import-apple-health-export/__tests__/route.test.ts": 5,
"app/api/admin/maintenance/measurements/__tests__/status.test.ts": 11,
"app/api/admin/maintenance/pulse-day-mean/__tests__/route.test.ts": 7,
"app/api/admin/notifications/diagnostic/__tests__/route.test.ts": 15,
"app/api/admin/notifications/health/__tests__/route.test.ts": 5,
"app/api/admin/notifications/reminder-check/__tests__/route.test.ts": 4,
"app/api/admin/notifications/test/__tests__/route.test.ts": 15,
"app/api/admin/provider-health/__tests__/route.test.ts": 29,
"app/api/admin/settings/__tests__/route.test.ts": 12,
"app/api/admin/settings/assistant-flags/__tests__/route.test.ts": 21,
"app/api/admin/settings/module-availability/__tests__/route.test.ts": 13,
"app/api/admin/settings/web-push-vapid/generate/__tests__/route.test.ts": 4,
"app/api/admin/status/__tests__/route.test.ts": 3,
"app/api/admin/users/__tests__/route.test.ts": 3,
"app/api/admin/users/[id]/__tests__/route.test.ts": 8,
"app/api/ai/test/__tests__/route.test.ts": 26,
"app/api/analytics/__tests__/route.test.ts": 25,
"app/api/auth/claim/__tests__/route.test.ts": 13,
"app/api/auth/login/__tests__/constant-work.test.ts": 4,
"app/api/auth/login/__tests__/native-token.test.ts": 8,
"app/api/auth/login/__tests__/route.test.ts": 5,
"app/api/auth/login/__tests__/trusted-device.test.ts": 2,
"app/api/auth/me/mfa/disable/__tests__/route.test.ts": 7,
"app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts": 8,
"app/api/auth/mfa/webauthn/verify/__tests__/route.test.ts": 11,
"app/api/auth/native/complete/__tests__/route.test.ts": 2,
"app/api/auth/native/login/__tests__/route.test.ts": 2,
"app/api/auth/native/token/__tests__/route.test.ts": 12,
"app/api/auth/oidc/callback/__tests__/route.test.ts": 18,
"app/api/auth/oidc/login/__tests__/route.test.ts": 4,
"app/api/auth/oidc/native/token/__tests__/route.test.ts": 11,
"app/api/auth/passkey/login-options/__tests__/oidc-only.test.ts": 2,
"app/api/auth/passkey/login-verify/__tests__/native-token.test.ts": 7,
"app/api/auth/passkey/login-verify/__tests__/oidc-only.test.ts": 4,
"app/api/auth/passkey/login-verify/__tests__/refusals.test.ts": 6,
"app/api/auth/passkeys/__tests__/route.test.ts": 3,
"app/api/auth/passkeys/[id]/__tests__/route.test.ts": 17,
"app/api/auth/password/__tests__/route.test.ts": 5,
"app/api/auth/register/__tests__/route.test.ts": 25,
"app/api/biomarkers/__tests__/error-contract.test.ts": 3,
"app/api/biomarkers/[id]/__tests__/delete-record-auth.test.ts": 3,
"app/api/biomarkers/[id]/__tests__/route.test.ts": 23,
"app/api/coach/plans/__tests__/route.test.ts": 23,
"app/api/coach/reminders/__tests__/route.test.ts": 15,
"app/api/coach/suggested-actions/__tests__/route.test.ts": 7,
"app/api/consent/ai/__tests__/route.test.ts": 10,
"app/api/consent/ai/latest/__tests__/route.test.ts": 42,
"app/api/consent/ai/web/__tests__/route.test.ts": 6,
"app/api/custom-metrics/__tests__/route.test.ts": 14,
"app/api/custom-metrics/[id]/__tests__/route.test.ts": 23,
"app/api/custom-metrics/[id]/entries/__tests__/note-encryption.test.ts": 13,
"app/api/custom-metrics/[id]/entries/__tests__/route.test.ts": 12,
"app/api/custom-metrics/[id]/entries/__tests__/soft-delete-restore.test.ts": 6,
"app/api/cycle/__tests__/external-id-floor.test.ts": 9,
"app/api/cycle/calendar/__tests__/day-entries.test.ts": 9,
"app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts": 26,
"app/api/cycle/day-logs/bulk/__tests__/route.test.ts": 6,
"app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts": 4,
"app/api/daily/digest/__tests__/route.test.ts": 2,
"app/api/daily/digest/dismiss/__tests__/route.test.ts": 2,
"app/api/dashboard/chart-overlay-prefs/__tests__/route.test.ts": 2,
"app/api/dashboard/snapshot/__tests__/route.test.ts": 5,
"app/api/dashboard/summary/__tests__/module-gate.test.ts": 6,
"app/api/dashboard/summary/__tests__/route.test.ts": 86,
"app/api/dashboard/widgets/__tests__/route.test.ts": 49,
"app/api/devices/__tests__/route.test.ts": 32,
"app/api/devices/[id]/__tests__/route.test.ts": 10,
"app/api/documents/inbound/__tests__/route.test.ts": 62,
"app/api/documents/inbound/[id]/__tests__/route.test.ts": 56,
"app/api/documents/inbound/[id]/chat/__tests__/route.test.ts": 42,
"app/api/documents/inbound/[id]/extract/__tests__/route.test.ts": 38,
"app/api/documents/inbound/[id]/index/__tests__/route.test.ts": 40,
"app/api/documents/inbound/[id]/original/__tests__/route.test.ts": 7,
"app/api/documents/inbound/[id]/suggest/__tests__/route.test.ts": 12,
"app/api/documents/inbound/[id]/summary/__tests__/route.test.ts": 31,
"app/api/documents/inbound/[id]/thumbnail/__tests__/route.test.ts": 5,
"app/api/documents/inbound/layout/__tests__/route.test.ts": 11,
"app/api/documents/inbound/reindex/__tests__/route.test.ts": 9,
"app/api/export/__tests__/measurement-streaming.test.ts": 9,
"app/api/export/__tests__/per-type-routes.test.ts": 100,
"app/api/export/__tests__/soft-delete-filter.test.ts": 39,
"app/api/export/encrypted/__tests__/route.test.ts": 20,
"app/api/export/health-record/__tests__/route.test.ts": 32,
"app/api/feature-flags/__tests__/route.test.ts": 6,
"app/api/fhir/__tests__/observation-series.test.ts": 8,
"app/api/fhir/__tests__/route.test.ts": 9,
"app/api/fitbit/connect/__tests__/route.test.ts": 4,
"app/api/gamification/achievements/__tests__/hidden-redaction.test.ts": 12,
"app/api/gamification/achievements/__tests__/ios-format.test.ts": 13,
"app/api/gamification/achievements/__tests__/module-gate.test.ts": 17,
"app/api/gamification/achievements/__tests__/writes-nothing.test.ts": 1,
"app/api/google-health/credentials/__tests__/route.test.ts": 1,
"app/api/google-health/sync/__tests__/route.test.ts": 3,
"app/api/google-health/sync/status/__tests__/route.test.ts": 1,
"app/api/illness/episodes/[id]/__tests__/body-site.test.ts": 9,
"app/api/illness/episodes/[id]/day-logs/__tests__/route.test.ts": 8,
"app/api/illness/episodes/[id]/restore/__tests__/route.test.ts": 9,
"app/api/import/__tests__/route.test.ts": 47,
"app/api/import/apple-health-export/__tests__/route.test.ts": 8,
"app/api/import/apple-health-export/[jobId]/status/__tests__/route.test.ts": 4,
"app/api/import/csv/__tests__/route.test.ts": 4,
"app/api/import/health-connect-export/__tests__/route.test.ts": 2,
"app/api/ingest/medication/__tests__/route.test.ts": 28,
"app/api/insights/biomarker-assessment/__tests__/route.test.ts": 6,
"app/api/insights/blood-pressure-status/__tests__/route.test.ts": 3,
"app/api/insights/cards/__tests__/route.test.ts": 18,
"app/api/insights/chat/[id]/attachments/__tests__/route.test.ts": 23,
"app/api/insights/chat/fenced/__tests__/route.test.ts": 18,
"app/api/insights/coach/facts/__tests__/route-memory.test.ts": 9,
"app/api/insights/coach/facts/__tests__/route.test.ts": 12,
"app/api/insights/comprehensive/__tests__/route.test.ts": 20,
"app/api/insights/correlations/__tests__/route.test.ts": 12,
"app/api/insights/derived/__tests__/route.test.ts": 12,
"app/api/insights/derived/batch/__tests__/route.test.ts": 21,
"app/api/insights/ecg/__tests__/route.test.ts": 5,
"app/api/insights/ecg/[id]/__tests__/route.test.ts": 5,
"app/api/insights/generate/__tests__/route.test.ts": 54,
"app/api/insights/layout/__tests__/route.test.ts": 23,
"app/api/insights/metric-status/__tests__/route.test.ts": 7,
"app/api/insights/mood-status/__tests__/route.test.ts": 10,
"app/api/insights/patterns/__tests__/route.test.ts": 1,
"app/api/insights/patterns/[id]/__tests__/route.test.ts": 4,
"app/api/insights/pregenerate/__tests__/route.test.ts": 4,
"app/api/insights/provider-chain/__tests__/put.test.ts": 4,
"app/api/insights/provider-chain/__tests__/route.test.ts": 13,
"app/api/insights/rhythm-events/__tests__/route.test.ts": 5,
"app/api/insights/targets/__tests__/route.test.ts": 1,
"app/api/integrations/fitbit/test/__tests__/route.test.ts": 18,
"app/api/integrations/healthkit/__tests__/route.test.ts": 48,
"app/api/integrations/status/__tests__/route.test.ts": 1,
"app/api/integrations/whoop/test/__tests__/route.test.ts": 15,
"app/api/integrations/withings/resume/__tests__/route.test.ts": 2,
"app/api/integrations/withings/test/__tests__/route.test.ts": 9,
"app/api/internal/deploy-webhook/__tests__/route.test.ts": 9,
"app/api/labs/__tests__/error-contract.test.ts": 4,
"app/api/labs/ocr/commit/__tests__/route.test.ts": 9,
"app/api/labs/ocr/extract/__tests__/route.test.ts": 17,
"app/api/labs/restore/__tests__/route.test.ts": 5,
"app/api/mcp/oauth/__tests__/authorize.test.ts": 29,
"app/api/mcp/oauth/__tests__/register.test.ts": 8,
"app/api/mcp/oauth/__tests__/token.test.ts": 14,
"app/api/mcp/tokens/__tests__/route.test.ts": 3,
"app/api/measurement-categories/__tests__/route.test.ts": 3,
"app/api/measurements/__tests__/arrival-wiring.test.ts": 7,
"app/api/measurements/__tests__/group-by-day.test.ts": 10,
"app/api/measurements/__tests__/put-duplicate-timestamp.test.ts": 9,
"app/api/measurements/__tests__/range-aggregation-route.test.ts": 10,
"app/api/measurements/__tests__/zod-multi-issue.test.ts": 2,
"app/api/measurements/[id]/__tests__/route.test.ts": 8,
"app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts": 9,
"app/api/measurements/batch/__tests__/duplicate-prefilter.test.ts": 1,
"app/api/measurements/batch/__tests__/hr-bucket-observability.test.ts": 3,
"app/api/measurements/batch/__tests__/insert-returning-arrivals.test.ts": 2,
"app/api/measurements/batch/__tests__/issue-paths.test.ts": 1,
"app/api/measurements/batch/__tests__/pr-detection-hook.test.ts": 3,
"app/api/measurements/batch/__tests__/refusal-detail.test.ts": 1,
"app/api/measurements/batch/__tests__/sync-trigger.test.ts": 6,
"app/api/measurements/batch/__tests__/timestamp-plausibility.test.ts": 2,
"app/api/measurements/batch/__tests__/unstable-external-id.test.ts": 2,
"app/api/measurements/bulk-delete/__tests__/route.test.ts": 10,
"app/api/measurements/by-external-ids/__tests__/route.test.ts": 6,
"app/api/measurements/restore/__tests__/route.test.ts": 10,
"app/api/measurements/series-batch/__tests__/route.test.ts": 10,
"app/api/measurements/series/__tests__/route.test.ts": 96,
"app/api/measurements/series/__tests__/unknown-query-param-open.test.ts": 6,
"app/api/medications/__tests__/route.test.ts": 43,
"app/api/medications/[id]/__tests__/route.test.ts": 52,
"app/api/medications/[id]/compliance/__tests__/route.test.ts": 18,
"app/api/medications/[id]/dose-history/__tests__/route.test.ts": 15,
"app/api/medications/[id]/glp1/__tests__/route.test.ts": 47,
"app/api/medications/[id]/intake/__tests__/route.test.ts": 45,
"app/api/medications/[id]/intake/__tests__/source-provenance.test.ts": 12,
"app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts": 63,
"app/api/medications/[id]/intake/import/__tests__/route.test.ts": 9,
"app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts": 24,
"app/api/medications/compliance/__tests__/route.test.ts": 10,
"app/api/medications/extract/__tests__/route.test.ts": 38,
"app/api/medications/intake/__tests__/route.test.ts": 59,
"app/api/medications/intake/bulk/__tests__/dose-arrival-scope.test.ts": 9,
"app/api/medications/intake/bulk/__tests__/route.test.ts": 68,
"app/api/medications/intake/dose-history-import/__tests__/route.test.ts": 7,
"app/api/medications/layout/__tests__/route.test.ts": 17,
"app/api/mental-health/assessments/__tests__/route.test.ts": 12,
"app/api/mental-health/assessments/[id]/__tests__/route.test.ts": 7,
"app/api/meta/capabilities/__tests__/route.test.ts": 1,
"app/api/monitoring/glitchtip/__tests__/route.test.ts": 2,
"app/api/monitoring/umami-script/__tests__/route.test.ts": 2,
"app/api/monitoring/umami/[...path]/__tests__/route.test.ts": 4,
"app/api/mood-entries/__tests__/route.test.ts": 6,
"app/api/mood-entries/[id]/__tests__/route.test.ts": 8,
"app/api/mood-entries/bulk-delete/__tests__/route.test.ts": 10,
"app/api/mood-entries/bulk/__tests__/route.test.ts": 17,
"app/api/mood-entries/restore/__tests__/route.test.ts": 11,
"app/api/mood/analytics/__tests__/route.test.ts": 15,
"app/api/mood/insights/__tests__/route.test.ts": 3,
"app/api/nightscout/sync/__tests__/route.test.ts": 3,
"app/api/notifications/action/[token]/__tests__/route.test.ts": 5,
"app/api/notifications/status/__tests__/route.test.ts": 4,
"app/api/notifications/web-push/__tests__/route.test.ts": 5,
"app/api/notifications/web-push/test/__tests__/route.test.ts": 9,
"app/api/nutrients/__tests__/route.test.ts": 5,
"app/api/nutrients/batch/__tests__/route.test.ts": 11,
"app/api/nutrients/daily/__tests__/route.test.ts": 7,
"app/api/nutrients/water/__tests__/route.test.ts": 8,
"app/api/onboarding/__tests__/error-contract.test.ts": 1,
"app/api/onboarding/complete/__tests__/route.test.ts": 4,
"app/api/oura/callback/__tests__/route.test.ts": 1,
"app/api/oura/sync/__tests__/route.test.ts": 3,
"app/api/personal-records/__tests__/route.test.ts": 15,
"app/api/polar/callback/__tests__/route.test.ts": 1,
"app/api/polar/sync/__tests__/route.test.ts": 3,
"app/api/share-links/__tests__/route.test.ts": 19,
"app/api/sleep/night/__tests__/route.test.ts": 24,
"app/api/sleep/rhythm/__tests__/route.test.ts": 6,
"app/api/sync/changes/__tests__/module-gate.test.ts": 7,
"app/api/sync/state/__tests__/route.test.ts": 8,
"app/api/telegram/webhook/__tests__/route.test.ts": 43,
"app/api/tokens/documents/__tests__/route.test.ts": 25,
"app/api/tokens/measurements/__tests__/route.test.ts": 25,
"app/api/tokens/workouts/__tests__/route.test.ts": 25,
"app/api/user/ai-provider/__tests__/legacy-private-host.test.ts": 2,
"app/api/user/ai-provider/__tests__/route.test.ts": 12,
"app/api/user/avatar/__tests__/route.test.ts": 13,
"app/api/user/avatar/[id]/__tests__/route.test.ts": 5,
"app/api/user/profile/__tests__/route.test.ts": 29,
"app/api/user/thresholds/__tests__/route.test.ts": 7,
"app/api/whoop/callback/__tests__/route.test.ts": 2,
"app/api/whoop/webhook/[token]/__tests__/route.test.ts": 5,
"app/api/withings/callback/__tests__/route.test.ts": 9,
"app/api/withings/connect/__tests__/route.test.ts": 4,
"app/api/withings/credentials/__tests__/route.test.ts": 4,
"app/api/withings/webhook/__tests__/route.test.ts": 26,
"app/api/withings/webhook/[token]/__tests__/route.test.ts": 8,
"app/api/workouts/__tests__/batch-create.test.ts": 13,
"app/api/workouts/__tests__/canonical-dedup.test.ts": 20,
"app/api/workouts/batch/__tests__/unstable-external-id.test.ts": 4,
"app/c/[token]/__tests__/report-download.test.ts": 11,
"app/c/[token]/d/[id]/__tests__/route.test.ts": 8,
"app/mcp/__tests__/route.test.ts": 1,
"components/day/__tests__/day-view.test.tsx": 1,
"components/documents/__tests__/document-record-links.test.tsx": 1,
"components/insights/__tests__/coincident-deviation-card.test.tsx": 1,
"components/insights/coach-panel/__tests__/chat-bubble-memo.test.tsx": 1,
"components/insights/coach-panel/__tests__/message-thread.test.tsx": 1,
"components/insights/coach-panel/__tests__/open-at-end.test.ts": 1,
"components/insights/coach-panel/__tests__/result-chart-compare.test.tsx": 2,
"components/insights/coach-panel/__tests__/turn-activity.test.tsx": 1,
"components/links/__tests__/vault-document-options.test.ts": 1,
"components/measurements/__tests__/measurement-list-url-sync.test.tsx": 1,
"components/medications/__tests__/side-effects-section.test.tsx": 3,
"components/medications/__tests__/use-medication-intake.test.ts": 1,
"components/medications/detail/efficacy/__tests__/change-at-start-summary.test.tsx": 1,
"components/mood/__tests__/mood-list-url-sync.test.tsx": 1,
"components/settings/__tests__/settings-shell.test.tsx": 1,
"components/settings/integrations/__tests__/dawarich-card.test.tsx": 1,
"components/timeline/__tests__/medication-rows.test.ts": 1,
"lib/__tests__/acting-account-resolver.test.ts": 5,
"lib/__tests__/auth-error-codes.test.ts": 7,
"lib/__tests__/db.test.ts": 2,
"lib/__tests__/doctor-report-data.test.ts": 2,
"lib/__tests__/doctor-report-module-backstop.test.ts": 3,
"lib/__tests__/doctor-report-recovery-canonical.test.ts": 1,
"lib/__tests__/idempotency.test.ts": 29,
"lib/__tests__/rate-limit-auth-surface.test.ts": 3,
"lib/__tests__/require-auth-bearer.test.ts": 20,
"lib/__tests__/require-fresh-mfa-if-enrolled.test.ts": 11,
"lib/__tests__/require-fresh-mfa-or-elevation-if-enrolled.test.ts": 15,
"lib/__tests__/require-fresh-mfa.test.ts": 32,
"lib/__tests__/require-recent-proof.test.ts": 16,
"lib/__tests__/require-record-auth-scope.test.ts": 8,
"lib/ai/__tests__/briefing-grounding.test.ts": 9,
"lib/ai/__tests__/codex-oauth.test.ts": 5,
"lib/ai/__tests__/openai-compatible-provider.test.ts": 2,
"lib/ai/__tests__/provider-admin-host.test.ts": 2,
"lib/ai/__tests__/provider-health-ledger.test.ts": 5,
"lib/ai/__tests__/provider-legacy-private-host.test.ts": 3,
"lib/ai/__tests__/provider-presence.test.ts": 2,
"lib/ai/__tests__/provider-reasoning-effort.test.ts": 5,
"lib/ai/__tests__/provider-response-timeout.test.ts": 2,
"lib/ai/__tests__/provider.test.ts": 35,
"lib/ai/__tests__/resolve-chain.test.ts": 12,
"lib/ai/__tests__/server-provider-health.test.ts": 1,
"lib/ai/capabilities/__tests__/egress.test.ts": 1,
"lib/ai/capabilities/__tests__/gate.test.ts": 1,
"lib/ai/coach/__tests__/budget.test.ts": 1,
"lib/ai/coach/__tests__/coach-memory-refresh-worker.test.ts": 1,
"lib/ai/coach/__tests__/context-reminders.test.ts": 5,
"lib/ai/coach/__tests__/conversation-summary.test.ts": 2,
"lib/ai/coach/__tests__/facts-deterministic.test.ts": 3,
"lib/ai/coach/__tests__/persistence-trail.test.ts": 1,
"lib/ai/coach/__tests__/plans.test.ts": 6,
"lib/ai/coach/__tests__/reminders.test.ts": 2,
"lib/ai/coach/__tests__/series-long-window.test.ts": 1,
"lib/ai/coach/__tests__/suggest-gate.test.ts": 1,
"lib/ai/coach/__tests__/tools-executor.test.ts": 1,
"lib/ai/coach/__tests__/tools-inventory.test.ts": 1,
"lib/ai/coach/__tests__/tools-loop.test.ts": 1,
"lib/ai/coach/results/__tests__/project.test.ts": 2,
"lib/ai/coach/snapshot-blocks/__tests__/core-metrics-pulse-day.test.ts": 2,
"lib/ai/coach/turn/__tests__/ledger-reasoning.test.ts": 2,
"lib/ai/coach/turn/__tests__/model-stable-prefix.test.ts": 2,
"lib/ai/prompts/__tests__/output-language-adoption.test.ts": 1,
"lib/ai/safety/__tests__/outbound-screen-guard2.test.ts": 1,
"lib/analytics/__tests__/mood-series.test.ts": 5,
"lib/analytics/__tests__/sleep-stage-composition.test.ts": 2,
"lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts": 1,
"lib/analytics/score/__tests__/record.test.ts": 2,
"lib/apple-health/__tests__/route-import.test.ts": 4,
"lib/arrivals/__tests__/reaction-line-shared.test.ts": 1,
"lib/auth/__tests__/audit.test.ts": 2,
"lib/auth/__tests__/bearer.test.ts": 16,
"lib/auth/__tests__/enrollment-proof-second-factor.test.ts": 12,
"lib/auth/__tests__/login-alert.test.ts": 9,
"lib/auth/__tests__/login-throttle.test.ts": 4,
"lib/auth/__tests__/mfa-enrollment.test.ts": 8,
"lib/auth/__tests__/native-handoff-flow.test.ts": 10,
"lib/auth/__tests__/oidc-native-handoff.test.ts": 13,
"lib/auth/__tests__/oidc.test.ts": 5,
"lib/auth/__tests__/passkey.test.ts": 7,
"lib/auth/__tests__/profile-update-email-limit.test.ts": 5,
"lib/auth/__tests__/profile-update.test.ts": 4,
"lib/auth/__tests__/session-locale-reconcile.test.ts": 1,
"lib/auth/__tests__/session.test.ts": 15,
"lib/auth/__tests__/trusted-device.test.ts": 10,
"lib/auth/__tests__/webauthn-library-contract.test.ts": 5,
"lib/auth/mfa/__tests__/challenge.test.ts": 14,
"lib/auth/mfa/__tests__/recovery-codes.test.ts": 8,
"lib/auth/mfa/__tests__/verify-factor.test.ts": 1,
"lib/cache/__tests__/server-cache.test.ts": 2,
"lib/consent/__tests__/receipts.test.ts": 11,
"lib/consent/__tests__/web-grant.test.ts": 9,
"lib/consent/__tests__/withdrawal.test.ts": 4,
"lib/cycle/__tests__/backup.test.ts": 3,
"lib/cycle/__tests__/prediction-refresh.test.ts": 9,
"lib/daily/__tests__/load-digest.test.ts": 32,
"lib/dashboard/__tests__/snapshot.test.ts": 1,
"lib/devices/__tests__/revoke.test.ts": 17,
"lib/doctor-report/__tests__/leaf-gating-sweep.test.ts": 2,
"lib/documents/__tests__/assist-kinds.test.ts": 1,
"lib/documents/__tests__/commit.test.ts": 9,
"lib/documents/__tests__/fenced-chat-pipeline.test.ts": 6,
"lib/documents/__tests__/index-document.test.ts": 23,
"lib/documents/__tests__/index-outcome-record.test.ts": 14,
"lib/documents/__tests__/prepare-vision-raster-failure.test.ts": 6,
"lib/documents/__tests__/provider-order.test.ts": 1,
"lib/documents/__tests__/rasterize-pdf-font-data.test.ts": 1,
"lib/documents/__tests__/upload-policy.test.ts": 8,
"lib/ecg/__tests__/persist-recording.test.ts": 7,
"lib/export/__tests__/account-settings-backup.test.ts": 1,
"lib/export/__tests__/coach-backup-title.test.ts": 3,
"lib/export/__tests__/full-backup-payload.test.ts": 25,
"lib/export/__tests__/intraday-profile-backup.test.ts": 3,
"lib/export/__tests__/paged-measurements.test.ts": 4,
"lib/export/__tests__/profile-backup-decrypt-failures.test.ts": 1,
"lib/export/__tests__/records-backup.test.ts": 8,
"lib/export/__tests__/reminders-backup-appointment-address.test.ts": 1,
"lib/export/__tests__/restore-backup-owner-mismatch.test.ts": 4,
"lib/export/__tests__/restore-backup-round-trip.test.ts": 5,
"lib/fhir/__tests__/conformance.test.ts": 3,
"lib/fhir/__tests__/rest-module-backstop.test.ts": 4,
"lib/fitbit/__tests__/sync-dead-token.test.ts": 10,
"lib/google-health/__tests__/sync-failsoft.test.ts": 6,
"lib/illness/__tests__/red-flag-notify.test.ts": 2,
"lib/import/__tests__/record-import-route.test.ts": 1,
"lib/insights/__tests__/background-reasoning-wire.test.ts": 2,
"lib/insights/__tests__/biomarker-status.test.ts": 23,
"lib/insights/__tests__/blood-pressure-status.test.ts": 32,
"lib/insights/__tests__/bmi-status.test.ts": 21,
"lib/insights/__tests__/briefing-provider-budget.test.ts": 2,
"lib/insights/__tests__/comprehensive-briefing-reroll.test.ts": 1,
"lib/insights/__tests__/comprehensive-generate-force.test.ts": 2,
"lib/insights/__tests__/feature-cache.test.ts": 2,
"lib/insights/__tests__/general-status.test.ts": 30,
"lib/insights/__tests__/glp1-plateau.test.ts": 17,
"lib/insights/__tests__/locale-pipeline.test.ts": 6,
"lib/insights/__tests__/medication-compliance-status.test.ts": 19,
"lib/insights/__tests__/memory.test.ts": 5,
"lib/insights/__tests__/metric-correlation-context.test.ts": 10,
"lib/insights/__tests__/metric-status.test.ts": 30,
"lib/insights/__tests__/mood-status.test.ts": 14,
"lib/insights/__tests__/payload-budget.test.ts": 13,
"lib/insights/__tests__/pulse-status-timeout.test.ts": 8,
"lib/insights/__tests__/pulse-status.test.ts": 33,
"lib/insights/__tests__/status-batch.test.ts": 33,
"lib/insights/__tests__/status-cache-delegated.test.ts": 2,
"lib/insights/__tests__/status-cache.test.ts": 39,
"lib/insights/__tests__/status-provider-budget.test.ts": 4,
"lib/insights/__tests__/unit-change-refresh.test.ts": 4,
"lib/insights/__tests__/weight-status.test.ts": 33,
"lib/insights/derived/__tests__/baseline.test.ts": 3,
"lib/insights/derived/__tests__/coach-read.test.ts": 1,
"lib/insights/derived/__tests__/derived-assessment-ai.test.ts": 3,
"lib/insights/derived/__tests__/recovery-resolve.test.ts": 1,
"lib/insights/derived/__tests__/sleep-score-oura-rhythm.test.ts": 2,
"lib/insights/derived/__tests__/sleep-score.test.ts": 3,
"lib/insights/derived/__tests__/trajectory.test.ts": 2,
"lib/insights/derived/__tests__/wellness-scores.test.ts": 2,
"lib/insights/narrative/__tests__/period-narrative-deterministic.test.ts": 1,
"lib/insights/narrative/__tests__/period-narrative-locale.test.ts": 3,
"lib/integrations/__tests__/admin-alert.test.ts": 5,
"lib/integrations/__tests__/apple-health-progress.test.ts": 2,
"lib/integrations/__tests__/sources-in-use.test.ts": 4,
"lib/integrations/__tests__/status.test.ts": 85,
"lib/jobs/__tests__/achievement-unlock-sweep.test.ts": 11,
"lib/jobs/__tests__/apple-health-import-reconcile.test.ts": 4,
"lib/jobs/__tests__/async-state-contract.test.ts": 32,
"lib/jobs/__tests__/coach-nudge.test.ts": 15,
"lib/jobs/__tests__/coach-reminder-sweep.test.ts": 11,
"lib/jobs/__tests__/compaction-tombstone-purge-failure.test.ts": 4,
"lib/jobs/__tests__/cycle-prediction-refresh.test.ts": 3,
"lib/jobs/__tests__/data-arrival-worker.test.ts": 18,
"lib/jobs/__tests__/document-ai-run.test.ts": 1,
"lib/jobs/__tests__/document-content-index-backfill.test.ts": 29,
"lib/jobs/__tests__/document-index.test.ts": 6,
"lib/jobs/__tests__/document-summary-reaper.test.ts": 1,
"lib/jobs/__tests__/document-summary.test.ts": 19,
"lib/jobs/__tests__/geo-backfill.test.ts": 1,
"lib/jobs/__tests__/insight-pregenerate.test.ts": 45,
"lib/jobs/__tests__/insight-status-generate.test.ts": 2,
"lib/jobs/__tests__/measurement-maintenance-schedule.test.ts": 2,
"lib/jobs/__tests__/measurement-reminder-byhour.test.ts": 2,
"lib/jobs/__tests__/measurement-reminder.test.ts": 33,
"lib/jobs/__tests__/medication-intake-import.test.ts": 1,
"lib/jobs/__tests__/medication-reminder-check.test.ts": 27,
"lib/jobs/__tests__/mood-reminder.test.ts": 15,
"lib/jobs/__tests__/morning-digest-refresh.test.ts": 6,
"lib/jobs/__tests__/oauth-callback-claim-cleanup.test.ts": 1,
"lib/jobs/__tests__/offhost-backup.test.ts": 5,
"lib/jobs/__tests__/offhost-purge.test.ts": 14,
"lib/jobs/__tests__/pr-detection.test.ts": 2,
"lib/jobs/__tests__/pulse-day-mean-rebuild-state.test.ts": 1,
"lib/jobs/__tests__/reminder-satisfy.test.ts": 9,
"lib/jobs/__tests__/workout-insight-generate-shared.test.ts": 2,
"lib/jobs/__tests__/workout-insight-generate.test.ts": 37,
"lib/jobs/__tests__/workout-insight-no-backfill-spend.test.ts": 3,
"lib/jobs/reminder/__tests__/queue-retention.test.ts": 2,
"lib/jobs/reminder/__tests__/registrar-shared.test.ts": 5,
"lib/labs/__tests__/ocr-capability.test.ts": 4,
"lib/labs/__tests__/ocr-rate-limit.test.ts": 1,
"lib/labs/__tests__/ocr-run.test.ts": 17,
"lib/labs/__tests__/reading-conversion.test.ts": 3,
"lib/labs/__tests__/resolve-or-mint-biomarker.test.ts": 1,
"lib/life-events/__tests__/service.test.ts": 1,
"lib/mcp/__tests__/admin-boundary.test.ts": 1,
"lib/mcp/__tests__/auth.test.ts": 1,
"lib/mcp/__tests__/nutrients-read.test.ts": 6,
"lib/mcp/__tests__/prompts.test.ts": 16,
"lib/mcp/__tests__/resource-templates.test.ts": 17,
"lib/mcp/__tests__/resources.test.ts": 8,
"lib/mcp/__tests__/rich-reads.test.ts": 1,
"lib/mcp/__tests__/search-fetch.test.ts": 12,
"lib/mcp/__tests__/server-resources.test.ts": 3,
"lib/mcp/__tests__/server-write-registration.test.ts": 3,
"lib/mcp/__tests__/tools.test.ts": 64,
"lib/mcp/__tests__/writes.test.ts": 1,
"lib/mcp/oauth/__tests__/connections.test.ts": 13,
"lib/measurement-reminders/__tests__/resolve.test.ts": 7,
"lib/measurement-reminders/__tests__/satisfy.test.ts": 18,
"lib/measurements/__tests__/categories.test.ts": 1,
"lib/measurements/__tests__/pick-canonical-workout-rows.test.ts": 2,
"lib/measurements/__tests__/series-canonical.test.ts": 1,
"lib/medications/__tests__/compliance-payload-heatmap.test.ts": 1,
"lib/medications/__tests__/compliance-payload-pre-creation.test.ts": 1,
"lib/medications/__tests__/intake-import-job-status.test.ts": 2,
"lib/medications/__tests__/intake-slot-dedup-converge.test.ts": 12,
"lib/medications/__tests__/intake-slot-dedup-inventory.test.ts": 7,
"lib/medications/__tests__/intake-slot-dedup-isolation.test.ts": 9,
"lib/medications/__tests__/intake-slot-dedup-pin.test.ts": 8,
"lib/medications/efficacy/__tests__/segmented-effect.test.ts": 1,
"lib/medications/ingredients/__tests__/label-extract.test.ts": 2,
"lib/medications/inventory/__tests__/consumption.test.ts": 8,
"lib/medications/inventory/__tests__/service.test.ts": 3,
"lib/medications/inventory/__tests__/summary.test.ts": 1,
"lib/medications/scheduling/__tests__/band-minter.test.ts": 1,
"lib/medications/scheduling/__tests__/dose-history.test.ts": 1,
"lib/medications/scheduling/__tests__/pin-conflict.test.ts": 2,
"lib/modules/__tests__/measurement-scope.test.ts": 3,
"lib/notifications/__tests__/admin-locale.test.ts": 7,
"lib/notifications/__tests__/client-managed-apns.test.ts": 9,
"lib/notifications/__tests__/dispatch-localised-cache.test.ts": 5,
"lib/notifications/__tests__/dispatcher-global-channel-switch.test.ts": 8,
"lib/notifications/__tests__/dispatcher.test.ts": 23,
"lib/notifications/__tests__/event-defaults.test.ts": 2,
"lib/notifications/__tests__/reminder-dedup.test.ts": 2,
"lib/notifications/senders/__tests__/apns.test.ts": 24,
"lib/notifications/senders/__tests__/ntfy.test.ts": 1,
"lib/notifications/senders/__tests__/push-attempt-record.test.ts": 14,
"lib/notifications/senders/__tests__/webhook.test.ts": 1,
"lib/onboarding/__tests__/first-result-config.test.ts": 1,
"lib/oura/__tests__/client.test.ts": 3,
"lib/polar/__tests__/client.test.ts": 2,
"lib/profile/__tests__/health-facts.test.ts": 1,
"lib/queries/__tests__/use-medication-list-layout.test.ts": 5,
"lib/sharing/__tests__/account-access.test.ts": 5,
"lib/sharing/__tests__/record-session-fence.test.ts": 1,
"lib/tz/__tests__/resolver.test.ts": 14,
"lib/vaccinations/__tests__/document-suggestion.test.ts": 1,
"lib/whoop/__tests__/sync-dead-token.test.ts": 6,
"lib/withings/__tests__/subscription-state.test.ts": 8,
"lib/withings/__tests__/sync-activity.test.ts": 37,
"lib/withings/__tests__/sync-ecg.test.ts": 12,
"lib/withings/__tests__/sync-measure-watermark.test.ts": 14,
"lib/withings/__tests__/sync-plan.test.ts": 13,
"lib/withings/__tests__/sync-plausibility.test.ts": 10,
"lib/withings/__tests__/sync-sleep.test.ts": 23,
"lib/withings/__tests__/sync-typed-classification.test.ts": 1,
"lib/workouts/__tests__/climb-backfill.test.ts": 1
}
}
@@ -0,0 +1,374 @@
{
"$comment": "tests that read a source file by path, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
"count": 367,
"total": 367,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 1,
"__tests__/admin-data-wipe-completeness.test.ts": 1,
"__tests__/aging-fixture-dates-guard.test.ts": 1,
"__tests__/ai-call-timeout-guard.test.ts": 1,
"__tests__/ai-client-abort-guard.test.ts": 1,
"__tests__/ai-consent-enforcement-guard.test.ts": 1,
"__tests__/ai-egress-capability-guard.test.ts": 1,
"__tests__/ai-reasoning-admin-guard.test.ts": 1,
"__tests__/ai-reasoning-effort-guard.test.ts": 1,
"__tests__/analysis-depth-no-ai-guard.test.ts": 1,
"__tests__/api-errors-leaf-guard.test.ts": 1,
"__tests__/apple-health-unit-audit-guard.test.ts": 1,
"__tests__/backfill-preserves-updated-at-guard.test.ts": 1,
"__tests__/background-reasoning-inventory.test.ts": 1,
"__tests__/background-task-settle-guard.test.ts": 1,
"__tests__/backup-plan-classification.test.ts": 1,
"__tests__/backup-readers-stream-guard.test.ts": 1,
"__tests__/backup-schema-lazy-guard.test.ts": 1,
"__tests__/client-bundle-server-import-guard.test.ts": 1,
"__tests__/client-poll-inventory-guard.test.ts": 1,
"__tests__/client-random-id-guard.test.ts": 1,
"__tests__/coach-focus-ring-guard.test.ts": 1,
"__tests__/coach-stream-lifecycle-guard.test.ts": 1,
"__tests__/coach-usage-owner-columns-guard.test.ts": 1,
"__tests__/confirm-destructive-style-guard.test.ts": 1,
"__tests__/consent-grant-explicit-guard.test.ts": 1,
"__tests__/container-scripts-ship-guard.test.ts": 1,
"__tests__/contract-stub-inventory.test.ts": 1,
"__tests__/copy-matches-behaviour-guard.test.ts": 1,
"__tests__/customize-shortcut-guard.test.ts": 1,
"__tests__/dashboard-summary-read-plan-guard.test.ts": 1,
"__tests__/dashboard-tile-eligibility-bucket-guard.test.ts": 1,
"__tests__/date-order-explicit-formatter-guard.test.ts": 1,
"__tests__/datetime-helper-guard.test.ts": 1,
"__tests__/dawarich-backup-completeness.test.ts": 1,
"__tests__/dense-measurement-read-guard.test.ts": 1,
"__tests__/derived-response-consumer-guard.test.ts": 1,
"__tests__/destructive-control-guard.test.ts": 1,
"__tests__/doctor-report-control-gating-guard.test.ts": 1,
"__tests__/document-sources-owner-only-guard.test.ts": 1,
"__tests__/domain-write-support-guard.test.ts": 1,
"__tests__/dose-ledger-single-source-guard.test.ts": 1,
"__tests__/dynamic-key-exhaustiveness.test.ts": 1,
"__tests__/e2e-conditional-skip-guard.test.ts": 1,
"__tests__/e2e-measurement-gating-guard.test.ts": 1,
"__tests__/e2e-route-cleanup-guard.test.ts": 1,
"__tests__/e2e-shared-session-guard.test.ts": 1,
"__tests__/empty-state-ratchet.test.ts": 1,
"__tests__/encounter-reminder-exclusion.test.ts": 1,
"__tests__/env-empty-is-unset-guard.test.ts": 1,
"__tests__/environment-backup-completeness.test.ts": 1,
"__tests__/error-code-catalogue.test.ts": 1,
"__tests__/error-display-helper-guard.test.ts": 1,
"__tests__/factor-verification-surface-guard.test.ts": 1,
"__tests__/feature-flags-mock-guard.test.ts": 1,
"__tests__/greeting-hour-single-source.test.ts": 1,
"__tests__/guard-floor-guard.test.ts": 1,
"__tests__/healthcheck-boot-storm.test.ts": 1,
"__tests__/i18n-call-site-coverage.test.ts": 1,
"__tests__/i18n-catalog-cache-key.test.ts": 1,
"__tests__/i18n-english-leak-guard.test.ts": 1,
"__tests__/inline-action-guard.test.ts": 1,
"__tests__/insights-unit-literal-guard.test.ts": 1,
"__tests__/intake-toast-single-decision.test.ts": 1,
"__tests__/integration-callback-url-guard.test.ts": 1,
"__tests__/integration-failure-response-consumer-guard.test.ts": 1,
"__tests__/inventory-write-isolation.test.ts": 1,
"__tests__/job-locale-resolution-guard.test.ts": 1,
"__tests__/job-queue-runtime-guard.test.ts": 1,
"__tests__/job-retention-floor-guard.test.ts": 1,
"__tests__/key-mismatch-refusal-guard.test.ts": 1,
"__tests__/list-cap-reader-guard.test.ts": 1,
"__tests__/list-page-guard.test.ts": 1,
"__tests__/malformed-json-status-guard.test.ts": 1,
"__tests__/manage-activity-inventory.test.ts": 1,
"__tests__/managed-profile-contract.test.ts": 1,
"__tests__/measurement-backup-completeness.test.ts": 1,
"__tests__/measurement-write-tail-guard.test.ts": 1,
"__tests__/medication-ingredient-backup-completeness.test.ts": 1,
"__tests__/medication-intake-tracking-guard.test.ts": 1,
"__tests__/migration-lock-timeout-guard.test.ts": 1,
"__tests__/module-gate-record-scoped.test.ts": 1,
"__tests__/mood-backup-completeness.test.ts": 1,
"__tests__/mood-prediction-write-surface-guard.test.ts": 1,
"__tests__/moodlog-removal-guard.test.ts": 1,
"__tests__/next-image-optimizer-guard.test.ts": 1,
"__tests__/no-server-actions-guard.test.ts": 1,
"__tests__/oidc-native-error-reasons-documented.test.ts": 1,
"__tests__/openapi-account-sharing-components.test.ts": 1,
"__tests__/openapi-route-coverage-guard.test.ts": 1,
"__tests__/openapi-sharing-denial.test.ts": 1,
"__tests__/page-header-h1-guard.test.ts": 1,
"__tests__/page-walk-guard.test.ts": 1,
"__tests__/password-breach-check-switch.test.ts": 1,
"__tests__/prefetch-page-caching-guard.test.ts": 1,
"__tests__/prisma-enum-mapping-guard.test.ts": 1,
"__tests__/provider-rounding-guard.test.ts": 1,
"__tests__/proxy-body-limit.test.ts": 1,
"__tests__/proxy-demo-mode-allowlist.test.ts": 1,
"__tests__/proxy-page-marker.test.ts": 1,
"__tests__/proxy-relative-redirect-guard.test.ts": 1,
"__tests__/published-env-reaches-the-container.test.ts": 1,
"__tests__/pulse-day-mean-reader-guard.test.ts": 1,
"__tests__/pwa-mobile-shell-guard.test.ts": 1,
"__tests__/query-error-error-prop-guard.test.ts": 1,
"__tests__/read-failure-markup-guard.test.ts": 1,
"__tests__/read-over-post-guard.test.ts": 1,
"__tests__/record-fence-headerless-transport-guard.test.ts": 1,
"__tests__/record-session-fence-acceptance-map.test.ts": 1,
"__tests__/record-session-fence-compat.test.ts": 1,
"__tests__/record-settings-route-guard.test.ts": 1,
"__tests__/record-settings-ui.test.tsx": 1,
"__tests__/record-write-rate-limit-contract.test.ts": 1,
"__tests__/relation-links-backup-completeness.test.ts": 1,
"__tests__/report-selection-egress-guard.test.ts": 1,
"__tests__/resolved-field-consumer-guard.test.ts": 1,
"__tests__/restore-lock-consolidation-guard.test.ts": 1,
"__tests__/rotation-walk-keyset-guard.test.ts": 1,
"__tests__/score-pill-guard.test.ts": 1,
"__tests__/self-gating-child-frame-guard.test.ts": 1,
"__tests__/service-worker-version-anchor.test.ts": 1,
"__tests__/service-worker.test.ts": 1,
"__tests__/session-user-projection-guard.test.ts": 1,
"__tests__/settings-legacy-routes.test.ts": 1,
"__tests__/sharing-accessibility-states.test.tsx": 1,
"__tests__/sharing-native-client-contract.test.ts": 1,
"__tests__/sharing-record-fence-contract.test.ts": 1,
"__tests__/sharing-recoverable-errors.test.tsx": 1,
"__tests__/success-affordance-guard.test.ts": 1,
"__tests__/sw-security-hardening.test.ts": 1,
"__tests__/text-field-focus-guard.test.ts": 1,
"__tests__/timezone-explicit-formatter-guard.test.ts": 1,
"__tests__/tombstone-read-guard.test.ts": 1,
"__tests__/translate-probe-guard.test.ts": 1,
"__tests__/ui-code-no-dash-separator.test.ts": 1,
"__tests__/unit-preference-display-guard.test.ts": 1,
"__tests__/unit-suite-browser-install-guard.test.ts": 1,
"__tests__/utc-day-key-allowlist-guard.test.ts": 1,
"__tests__/v1341-locale-parity.test.ts": 1,
"__tests__/visit-link-guard.test.ts": 1,
"__tests__/weight-tile-direction-guard.test.ts": 1,
"__tests__/workout-canonical-identity-guard.test.ts": 1,
"__tests__/write-rate-limit-guard.test.ts": 1,
"__tests__/written-outcome-response-consumer-guard.test.ts": 1,
"__tests__/zod-refusal-issue-list-guard.test.ts": 1,
"app/__tests__/dashboard-analytics-split.test.ts": 1,
"app/__tests__/dashboard-chart-reveal.test.tsx": 1,
"app/__tests__/dashboard-demo-checklist.test.ts": 1,
"app/__tests__/dashboard-hero-wiring.test.ts": 1,
"app/__tests__/dashboard-hrv-rmssd-fallback.test.tsx": 1,
"app/__tests__/dashboard-suspense-boundaries.test.ts": 1,
"app/__tests__/dashboard-tile-strip-skeleton.test.ts": 1,
"app/__tests__/i18n-boot-script-strategy.test.ts": 1,
"app/__tests__/insights-b3-wiring.test.ts": 1,
"app/__tests__/insights-polish.test.ts": 1,
"app/__tests__/not-found-copy.test.ts": 1,
"app/__tests__/quick-add-labels.test.ts": 1,
"app/__tests__/root-loading-silhouette.test.tsx": 1,
"app/api/__tests__/ai-capability-route-inventory.test.ts": 1,
"app/api/__tests__/inline-schema-strictness-inventory.test.ts": 1,
"app/api/__tests__/module-route-gate-inventory.test.ts": 1,
"app/api/__tests__/read-routes-write-nothing.test.ts": 1,
"app/api/__tests__/webhook-verb-rate-limit-guard.test.ts": 1,
"app/api/auth/native/__tests__/invariants.test.ts": 1,
"app/api/auth/passkeys/__tests__/route.test.ts": 1,
"app/api/insights/__tests__/coach-route-gate-inventory.test.ts": 1,
"app/api/mood-entries/__tests__/bulk-dimension-bounds.test.ts": 1,
"app/api/polar/sync/__tests__/route.test.ts": 1,
"app/api/settings/__tests__/private-origin-routes.test.ts": 1,
"app/api/user/thresholds/__tests__/route.test.ts": 1,
"app/insights/__tests__/assessment-spine-order.test.ts": 1,
"app/insights/__tests__/explainer-metric-keys.test.ts": 1,
"app/insights/__tests__/header-add-pattern.test.ts": 1,
"app/insights/__tests__/page-skeletons.test.ts": 1,
"app/insights/sleep/__tests__/empty-state-href.test.ts": 1,
"app/measurements/__tests__/add-token-resolution.test.ts": 1,
"app/medications/__tests__/detail-page-history-surface.test.ts": 1,
"components/__tests__/design-consistency-guards.test.tsx": 1,
"components/__tests__/failed-read-never-empty.test.tsx": 1,
"components/__tests__/motion-reduce-spin-coverage.test.ts": 1,
"components/admin/__tests__/admin-shell.test.tsx": 1,
"components/admin/__tests__/notification-test-failures.test.ts": 1,
"components/charts/__tests__/chart-error-state.test.tsx": 1,
"components/charts/__tests__/health-chart-overlay-defaults.test.tsx": 1,
"components/charts/__tests__/health-chart-target-link-removed.test.ts": 1,
"components/charts/__tests__/mood-chart-polish.test.tsx": 1,
"components/charts/__tests__/touch-action-guard.test.ts": 1,
"components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx": 1,
"components/dashboard/__tests__/medication-intake-quick-add.test.tsx": 1,
"components/dashboard/__tests__/tile-destinations.test.ts": 1,
"components/day/__tests__/day-door-coverage-guard.test.ts": 1,
"components/day/__tests__/day-not-here-guard.test.ts": 1,
"components/documents/__tests__/document-coach-launch-wiring.test.ts": 1,
"components/documents/__tests__/document-detail-download.test.ts": 1,
"components/documents/__tests__/documents-delete-confirm-guard.test.ts": 1,
"components/forms/__tests__/use-seeded-form-dismissal.test.ts": 1,
"components/i18n/__tests__/maintainership-banner.test.tsx": 1,
"components/insights/__tests__/coach-metric-scope-inventory.test.ts": 1,
"components/insights/__tests__/correlation-card-skeleton.test.ts": 1,
"components/insights/__tests__/health-score-card-geometry.test.tsx": 1,
"components/insights/__tests__/health-score-coverage-axis-guard.test.tsx": 1,
"components/insights/__tests__/insight-section-card.test.tsx": 1,
"components/insights/__tests__/sleep-overview-empty-href.test.ts": 1,
"components/insights/__tests__/tile-header-wrap.test.tsx": 1,
"components/insights/coach-panel/__tests__/coach-settings-overlay.test.tsx": 1,
"components/insights/coach-panel/__tests__/conversations-panel-fold.test.tsx": 1,
"components/insights/coach-panel/__tests__/message-thread.test.tsx": 1,
"components/insights/coach-panel/__tests__/open-at-end.test.ts": 1,
"components/insights/coach-panel/__tests__/panel-touch-targets.test.ts": 1,
"components/insights/coach-panel/__tests__/scroll-to-bottom-button.test.tsx": 1,
"components/insights/mood/__tests__/mood-notes-timeline-removed.test.ts": 1,
"components/layout/__tests__/auth-shell-coach-hoist.test.ts": 1,
"components/layout/__tests__/auth-shell-shared-record-doors.test.tsx": 1,
"components/layout/__tests__/offline-banner.test.tsx": 1,
"components/layout/__tests__/sidebar-nav.test.tsx": 1,
"components/measurements/__tests__/duration-read-as.test.tsx": 1,
"components/measurements/__tests__/measurement-form-glucose-unit.test.tsx": 1,
"components/measurements/__tests__/measurement-list-glucose-unit.test.tsx": 1,
"components/medications/__tests__/add-inventory-dialog.test.tsx": 1,
"components/medications/__tests__/detail-page-cascade.test.tsx": 1,
"components/medications/__tests__/inventory-section-dates.test.tsx": 1,
"components/medications/__tests__/log-injection-site-dialog.test.tsx": 1,
"components/medications/__tests__/medication-order-editor.test.tsx": 1,
"components/medications/__tests__/medication-table.test.tsx": 1,
"components/medications/__tests__/take-all-due.test.tsx": 1,
"components/mood/__tests__/linked-day-timezone.test.ts": 1,
"components/mood/__tests__/mood-note-fulltext.test.tsx": 1,
"components/mood/__tests__/mood-qol-fixes.test.tsx": 1,
"components/onboarding/__tests__/tour-launcher-gate.test.ts": 1,
"components/records/__tests__/emergency-profile-save.test.tsx": 1,
"components/settings/__tests__/action-feedback.test.ts": 1,
"components/settings/__tests__/card-description-length.test.ts": 1,
"components/settings/__tests__/confirm-style-guard.test.ts": 1,
"components/settings/__tests__/dashboard-layout-snapshot-invalidation.test.ts": 1,
"components/settings/__tests__/destructive-controls-confirm.test.tsx": 1,
"components/settings/__tests__/glucose-unit-select.test.tsx": 1,
"components/settings/__tests__/kept-grant-revoke-confirm.test.ts": 1,
"components/settings/__tests__/module-description-length.test.ts": 1,
"components/settings/__tests__/mood-reminder-card.test.tsx": 1,
"components/settings/__tests__/oauth-return-outcome.test.ts": 1,
"components/settings/__tests__/one-piece-anatomy.test.tsx": 1,
"components/settings/__tests__/password-card-placement.test.tsx": 1,
"components/settings/__tests__/save-model-guard.test.ts": 1,
"components/settings/__tests__/score-refusal-consumer-guard.test.ts": 1,
"components/settings/__tests__/settings-card-header-coverage.test.ts": 1,
"components/settings/__tests__/settings-shell.test.tsx": 1,
"components/settings/__tests__/test-connection-button-response.test.tsx": 1,
"components/settings/__tests__/token-revoke-labels.test.ts": 1,
"components/settings/access/__tests__/grant-access-level.test.tsx": 1,
"components/settings/access/__tests__/grant-invite-expiry.test.tsx": 1,
"components/settings/access/__tests__/grant-invite-scope.test.tsx": 1,
"components/settings/access/__tests__/managed-profile-affordances.test.tsx": 1,
"components/settings/account-section/__tests__/account-section-field-error-clearing.test.ts": 1,
"components/settings/account-section/__tests__/display-name-field.test.tsx": 1,
"components/settings/account-section/__tests__/display-preferences-card.test.tsx": 1,
"components/settings/import-panel/__tests__/import-docs-link.test.ts": 1,
"components/settings/integrations/__tests__/cards-render-health.test.ts": 1,
"components/settings/integrations/__tests__/google-health-card.test.tsx": 1,
"components/settings/security-section/__tests__/factor-enrollment-reauth.test.tsx": 1,
"components/settings/security-section/__tests__/passkey-enrollment-reauth.test.tsx": 1,
"components/settings/security-section/__tests__/passkey-removal-step-up.test.tsx": 1,
"components/settings/security-section/__tests__/recent-proof-callers.test.tsx": 1,
"components/settings/security-section/__tests__/totp-card.test.tsx": 1,
"components/timeline/__tests__/label-keys.test.ts": 1,
"components/timeline/__tests__/no-template-key-guard.test.ts": 1,
"components/ui/__tests__/card-density.test.tsx": 1,
"components/ui/__tests__/card-header-rows.test.ts": 1,
"components/ui/__tests__/filter-bar.test.tsx": 1,
"components/ui/__tests__/segmented.test.tsx": 1,
"hooks/__tests__/use-insight-status.test.ts": 1,
"lib/__tests__/geo-offline-detection.test.ts": 1,
"lib/__tests__/query-keys.test.ts": 1,
"lib/__tests__/ssrf-pin-inventory.test.ts": 1,
"lib/ai/__tests__/provider-state-never-persisted.test.ts": 1,
"lib/ai/coach/__tests__/coach-availability-window-honesty.test.ts": 1,
"lib/ai/coach/__tests__/coach-history-reach-guard.test.ts": 1,
"lib/ai/coach/__tests__/coach-locale-no-german-default.test.ts": 1,
"lib/ai/coach/__tests__/data-field-injection.test.ts": 1,
"lib/ai/coach/__tests__/snapshot.test.ts": 1,
"lib/ai/coach/memory/__tests__/contract.test.ts": 1,
"lib/ai/coach/tools/__tests__/turn-budget.test.ts": 1,
"lib/ai/coach/turn/__tests__/steps.test.ts": 1,
"lib/analytics/__tests__/glucose-panel-window.test.ts": 1,
"lib/analytics/compliance/__tests__/as-needed-compliance-exclusion.test.ts": 1,
"lib/analytics/mood-prognosis/__tests__/gates.test.ts": 1,
"lib/analytics/score/__tests__/reference-citation-guard.test.ts": 1,
"lib/auth/__tests__/step-up.test.ts": 1,
"lib/charts/__tests__/axis-unit.test.ts": 1,
"lib/charts/__tests__/data-ready-latch.test.ts": 1,
"lib/charts/__tests__/fetch-window.test.ts": 1,
"lib/command-palette/__tests__/settings-cards.test.ts": 1,
"lib/crypto/__tests__/encrypted-column-writers.test.ts": 1,
"lib/crypto/__tests__/encrypted-columns.test.ts": 1,
"lib/dashboard/__tests__/snapshot.test.ts": 1,
"lib/documents/__tests__/fenced-chat-module-graph.test.ts": 1,
"lib/documents/__tests__/rasterize-pdf-font-data.test.ts": 1,
"lib/export/__tests__/full-backup-stream-heap-gauge.test.ts": 1,
"lib/export/__tests__/intraday-profile-backup.test.ts": 1,
"lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts": 1,
"lib/fitbit/__tests__/sync-module-graph.test.ts": 1,
"lib/format/__tests__/bytes.test.ts": 1,
"lib/google-health/__tests__/sync-module-graph.test.ts": 1,
"lib/http/__tests__/deprecation.test.ts": 1,
"lib/i18n/__tests__/locale-first-paint-single-write.test.tsx": 1,
"lib/i18n/__tests__/plural-locale-completeness.test.ts": 1,
"lib/import/__tests__/record-import-rows.test.ts": 1,
"lib/insights/__tests__/prompt-soft-delete-filter.test.ts": 1,
"lib/insights/eval/__tests__/tone-harness.test.ts": 1,
"lib/jobs/__tests__/ai-job-capability-inventory.test.ts": 1,
"lib/jobs/__tests__/apple-health-import-ecg.test.ts": 1,
"lib/jobs/__tests__/apple-health-import-export-date.test.ts": 1,
"lib/jobs/__tests__/apple-health-import-reconcile.test.ts": 1,
"lib/jobs/__tests__/apple-health-import-send-policy.test.ts": 1,
"lib/jobs/__tests__/coach-memory-refresh-queue.test.ts": 1,
"lib/jobs/__tests__/coach-message-cleanup-queue.test.ts": 1,
"lib/jobs/__tests__/cumulative-pr-rederive-queue.test.ts": 1,
"lib/jobs/__tests__/cycle-reminder-queue.test.ts": 1,
"lib/jobs/__tests__/daily-briefing.test.ts": 1,
"lib/jobs/__tests__/data-arrival-provider-isolation.test.ts": 1,
"lib/jobs/__tests__/data-arrival-queue.test.ts": 1,
"lib/jobs/__tests__/data-backup-send-policy.test.ts": 1,
"lib/jobs/__tests__/drain-cumulative-queue.test.ts": 1,
"lib/jobs/__tests__/fitbit-queues.test.ts": 1,
"lib/jobs/__tests__/geo-backfill.test.ts": 1,
"lib/jobs/__tests__/geolite2-fetch-queue.test.ts": 1,
"lib/jobs/__tests__/google-health-queues.test.ts": 1,
"lib/jobs/__tests__/insight-pregenerate.test.ts": 1,
"lib/jobs/__tests__/insight-status-generate.test.ts": 1,
"lib/jobs/__tests__/intake-slot-dedup-queue.test.ts": 1,
"lib/jobs/__tests__/integration-backfill-admission.test.ts": 1,
"lib/jobs/__tests__/job-failures-retention.test.ts": 1,
"lib/jobs/__tests__/job-outcome-source-inventory.test.ts": 1,
"lib/jobs/__tests__/mcp-token-cleanup-queue.test.ts": 1,
"lib/jobs/__tests__/mean-consolidation-queue.test.ts": 1,
"lib/jobs/__tests__/measurement-reminder-queue.test.ts": 1,
"lib/jobs/__tests__/measurement-tombstone-cleanup.test.ts": 1,
"lib/jobs/__tests__/nightscout-queues.test.ts": 1,
"lib/jobs/__tests__/period-narrative-warm.test.ts": 1,
"lib/jobs/__tests__/queue-policy-tables.test.ts": 1,
"lib/jobs/__tests__/read-side-effect-queues.test.ts": 1,
"lib/jobs/__tests__/recovery-score-queue.test.ts": 1,
"lib/jobs/__tests__/sleep-timeline-queues.test.ts": 1,
"lib/jobs/__tests__/status-cron-candidates.test.ts": 1,
"lib/jobs/__tests__/step-consolidation-repair-queue.test.ts": 1,
"lib/jobs/__tests__/stress-strain-retention-queue.test.ts": 1,
"lib/jobs/__tests__/subsystem-surface.test.ts": 1,
"lib/jobs/__tests__/tls-pin-monitor-queue.test.ts": 1,
"lib/jobs/__tests__/whoop-queues.test.ts": 1,
"lib/jobs/__tests__/withings-ecg-worker.test.ts": 1,
"lib/jobs/__tests__/withings-queues.test.ts": 1,
"lib/jobs/__tests__/workout-insight-queue.test.ts": 1,
"lib/jobs/reminder/__tests__/integration-cohort-outcomes.test.ts": 1,
"lib/labs/__tests__/local-ocr.test.ts": 1,
"lib/links/__tests__/link-surface-guard.test.ts": 1,
"lib/links/__tests__/relation-matrix.test.ts": 1,
"lib/medications/__tests__/auto-mark-taken.test.ts": 1,
"lib/medications/__tests__/glp1-knowledge-drift.test.ts": 1,
"lib/medications/inventory/__tests__/summary.test.ts": 1,
"lib/modules/__tests__/surface-module-registry.test.ts": 1,
"lib/mood/__tests__/context-vocabulary.test.ts": 1,
"lib/notifications/__tests__/event-defaults.test.ts": 1,
"lib/pwa/__tests__/sw-cache-policy.test.ts": 1,
"lib/record-activity/__tests__/verb-map-covers-routes.test.ts": 1,
"lib/sharing/__tests__/record-session-fence.test.ts": 1,
"lib/sources/__tests__/source-priority-whoop-applehealth.test.ts": 1,
"lib/whoop/__tests__/sync-module-graph.test.ts": 1,
"lib/whoop/__tests__/sync-user-whoop.test.ts": 1
}
}
File diff suppressed because it is too large. Load diff
@@ -13,10 +13,13 @@
* for that review.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
import {
readSource,
readSourceText,
walkSourceFiles,
} from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
@@ -33,7 +36,7 @@ function sourceFiles(): string[] {
}
function read(rel: string): string {
return readFileSync(join(SRC, rel), "utf8");
return readSourceText(join(SRC, rel));
}
function filesMatching(re: RegExp): string[] {
@@ -277,7 +280,10 @@ describe("T1 — the Bearer resolution set is frozen", () => {
});
it("the resolver exposes exactly one authorisation arm", () => {
const src = read("lib/auth/bearer.ts");
const src = readSource(
"src/lib/auth/bearer.ts",
'permissions.includes("*")',
);
// One wildcard escape hatch, guarding one deny block. If a second
// `permissions.includes("*")` short-circuit appears, the arm has forked.
const wildcardChecks = src.match(/permissions\.includes\("\*"\)/g) ?? [];
@@ -376,7 +382,10 @@ describe("T3 — the mint sites are frozen", () => {
// does not contradict this: what was retired is a mint at THIS path that
// named no scope and answered for the whole API. A child path minting one
// named scope, listed in `MINT_SITES` above, is the shape that replaced it.
const tokensRoute = read("app/api/tokens/route.ts");
const tokensRoute = readSource(
"src/app/api/tokens/route.ts",
"export const GET",
);
expect(tokensRoute).not.toMatch(/export const POST/);
expect(tokensRoute).toMatch(/export const GET/);
});
@@ -443,12 +452,18 @@ describe("T4b — `documents:write` opens exactly two doors", () => {
]);
// Within the upload file, once: the GET list beside the POST must not
// name it.
const upload = read("app/api/documents/inbound/route.ts");
const upload = readSource(
"src/app/api/documents/inbound/route.ts",
"DOCUMENTS_WRITE_SCOPE",
);
expect(
requireAuthScopes(upload).filter((x) => x === "DOCUMENTS_WRITE_SCOPE"),
).toHaveLength(1);
// And the lookup is a read of one key and nothing else: a single GET.
const lookup = read("app/api/documents/inbound/source/route.ts");
const lookup = readSource(
"src/app/api/documents/inbound/source/route.ts",
"export const GET",
);
expect(
[...lookup.matchAll(/export const (GET|POST|PUT|PATCH|DELETE)\b/g)].map(
(m) => m[1],
@@ -470,7 +485,10 @@ describe("T4c — `workouts:write` opens exactly one door", () => {
);
});
expect(declaring).toEqual(["app/api/workouts/batch/route.ts"]);
const batch = read("app/api/workouts/batch/route.ts");
const batch = readSource(
"src/app/api/workouts/batch/route.ts",
"WORKOUTS_WRITE_SCOPE",
);
expect(
requireAuthScopes(batch).filter((x) => x === "WORKOUTS_WRITE_SCOPE"),
).toHaveLength(1);
@@ -525,7 +543,7 @@ describe("T5 — a delegable route naming a Bearer scope is frozen", () => {
// it and every other leg in this file stays green while a single-purpose
// token gains delegated write — so the refusal is asserted where it lives,
// not only through the behaviour that depends on it.
const src = read("lib/api-handler.ts");
const src = readSource("src/lib/api-handler.ts", "narrow_scope_selector");
expect(src).toMatch(/narrow_scope_selector/);
expect(src).toMatch(/isScopedCredential\(auth\)/);
});
@@ -28,10 +28,9 @@
* outside `app/api/`); a server-side `take` is not a fetch.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
import { readSourceText, walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
@@ -178,7 +177,7 @@ function findLimitHits(file: string, source: string): Hit[] {
function allHits(): Hit[] {
return clientFiles().flatMap((file) =>
findLimitHits(file, readFileSync(join(SRC, file), "utf8")),
findLimitHits(file, readSourceText(join(SRC, file))),
);
}
+4 -3
View File
@@ -20,9 +20,10 @@
* loaded chunk may still carry zod.
*/
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { existsSync } from "node:fs";
import { dirname, join, normalize } from "node:path";
import ts from "typescript";
import { readSourceText } from "./helpers/source-files";
const ROOT = join(__dirname, "..", "..");
@@ -90,7 +91,7 @@ function clientZodImports(entry: string): { file: string; path: string[] }[] {
const [file, inClient] = stack.pop()!;
const prior = seen.get(file);
if (prior === true || (prior === false && !inClient)) continue;
const source = readFileSync(file, "utf8");
const source = readSourceText(file);
const client = inClient || isClient(source);
seen.set(file, client);
walked += 1;
@@ -141,7 +142,7 @@ describe("client routes without the schema library", () => {
// The schema module is zod by definition; walked as a client entry it
// must be reported, or the walk above proves nothing.
const probe = join(ROOT, "src/lib/validations/inbound-documents.ts");
const source = readFileSync(probe, "utf8");
const source = readSourceText(probe);
expect(staticImports(source, probe).some((s) => s.startsWith("zod"))).toBe(
true,
);
Loaded 100 of 795 files, more files were not shown because too many files have changed in this diff. Show more