mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Compare commits
205
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
957c2f279e | ||
|
|
c68e833dcc | ||
|
|
cc04e0944d | ||
|
|
14d0184b63 | ||
|
|
cf61bd1c85 | ||
|
|
8c43ff7cf9 | ||
|
|
3b2ebc1081 | ||
|
|
d8e4cf6000 | ||
|
|
333dfe8746 | ||
|
|
b09a6d2df8 | ||
|
|
2844088251 | ||
|
|
294be53883 | ||
|
|
dda4c57f5a | ||
|
|
c10446f0d7 | ||
|
|
a787cad7f9 | ||
|
|
2ed863f877 | ||
|
|
67135b2ce8 | ||
|
|
70c1077121 | ||
|
|
bafda973ee | ||
|
|
239319a762 | ||
|
|
5dab1488d7 | ||
|
|
8a9bcffaa2 | ||
|
|
bf12b9840c | ||
|
|
9da0e0c405 | ||
|
|
32b71a7df9 | ||
|
|
6d23cca4a3 | ||
|
|
562ff8a277 | ||
|
|
811fcf5509 | ||
|
|
038f8b10cd | ||
|
|
6c4c097517 | ||
|
|
cec7ce2ad5 | ||
|
|
760f261a5f | ||
|
|
298515d156 | ||
|
|
422adf2986 | ||
|
|
3b2c2c45f3 | ||
|
|
faedcbba93 | ||
|
|
6ab1f2d8a2 | ||
|
|
7fda400a8c | ||
|
|
ac31130bc0 | ||
|
|
49a999ab94 | ||
|
|
c15c1d18f3 | ||
|
|
e78d5f86a6 | ||
|
|
466dfb3785 | ||
|
|
3354631a85 | ||
|
|
47ac2c6889 | ||
|
|
38941c55d8 | ||
|
|
5448a43651 | ||
|
|
3decab47a0 | ||
|
|
d600d6f9d2 | ||
|
|
a3bf826fe7 | ||
|
|
f38954f2ae | ||
|
|
d6ed165ac6 | ||
|
|
cdad3057cf | ||
|
|
9c8972bc96 | ||
|
|
8e7788132a | ||
|
|
abb79ee6ae | ||
|
|
9497b62b20 | ||
|
|
a0e97a4e54 | ||
|
|
b89cb6a29f | ||
|
|
dcb2598bb2 | ||
|
|
996cc15b50 | ||
|
|
8c11bb8385 | ||
|
|
f987efd9f3 | ||
|
|
50a6247177 | ||
|
|
945e998516 | ||
|
|
6c15a66cde | ||
|
|
36a1485b91 | ||
|
|
2d16183b58 | ||
|
|
dc9beaefb7 | ||
|
|
1bc5df17bc | ||
|
|
10bd2e5d94 | ||
|
|
51f7d38b3c | ||
|
|
71123e5fae | ||
|
|
5c2f91463c | ||
|
|
fed49f9b76 | ||
|
|
34ee0ea6e8 | ||
|
|
4df28c5f80 | ||
|
|
4e47c52aed | ||
|
|
3577da6966 | ||
|
|
68b428e3f5 | ||
|
|
929d83b3b6 | ||
|
|
764794496c | ||
|
|
6cf69bbf1c | ||
|
|
1619b2406a | ||
|
|
402daa2753 | ||
|
|
7363bcd5d3 | ||
|
|
e18cd086fd | ||
|
|
98a083c86e | ||
|
|
8fc4941200 | ||
|
|
9e2a8229ec | ||
|
|
6d250d092d | ||
|
|
221960af88 | ||
|
|
bb3ee6b855 | ||
|
|
8ea0ba5297 | ||
|
|
f08277e595 | ||
|
|
ed04103eb6 | ||
|
|
803d90c6c4 | ||
|
|
ac9693fac9 | ||
|
|
03b3f6a3ba | ||
|
|
aae7bce078 | ||
|
|
6cd8ffa526 | ||
|
|
c496e390be | ||
|
|
08675a4f13 | ||
|
|
7bbe64ad1a | ||
|
|
190a83c513 | ||
|
|
6a2247e422 | ||
|
|
743a1e742f | ||
|
|
4dc3bd46bf | ||
|
|
ff49bbb5fc | ||
|
|
90106d88c8 | ||
|
|
72e6fd6620 | ||
|
|
55211c0e69 | ||
|
|
1cc1cda7aa | ||
|
|
601d5ea447 | ||
|
|
703c92c776 | ||
|
|
0cefac9d68 | ||
|
|
6dfb7eada6 | ||
|
|
3524d7c289 | ||
|
|
a451a114a2 | ||
|
|
83661319d2 | ||
|
|
4c86b68363 | ||
|
|
f50b6b6637 | ||
|
|
8f1f4dc691 | ||
|
|
3a4dc16e1d | ||
|
|
5902401ab8 | ||
|
|
1fa33f2aee | ||
|
|
44c2973a64 | ||
|
|
76af868064 | ||
|
|
4fc6b0e386 | ||
|
|
6f20ae4a8d | ||
|
|
12749f6839 | ||
|
|
5a9a6ce78b | ||
|
|
322652a86e | ||
|
|
508e19f190 | ||
|
|
5199df56db | ||
|
|
5d3794e734 | ||
|
|
eda90eeb56 | ||
|
|
a57fa8335a | ||
|
|
f8a88d1361 | ||
|
|
e00943ff7f | ||
|
|
f5d6710670 | ||
|
|
b6e9eb98f7 | ||
|
|
7248347188 | ||
|
|
d396945f44 | ||
|
|
579789ec38 | ||
|
|
5ca3994523 | ||
|
|
c272324c1e | ||
|
|
4f2af7c544 | ||
|
|
901cd884e8 | ||
|
|
850988c832 | ||
|
|
b5f5a5a580 | ||
|
|
e7b31fe8e0 | ||
|
|
96062d198a | ||
|
|
01c935261b | ||
|
|
73362dc0ae | ||
|
|
dcf49dd14e | ||
|
|
66f8b18cf8 | ||
|
|
a859adb59c | ||
|
|
a20cf17539 | ||
|
|
27e83f1a83 | ||
|
|
2ccac728f4 | ||
|
|
7e932c4740 | ||
|
|
63e8630fcb | ||
|
|
64fa65b460 | ||
|
|
0f6d668df1 | ||
|
|
d98920415e | ||
|
|
f40cdd3dc3 | ||
|
|
ae2636c9e5 | ||
|
|
f5d4dd2ec6 | ||
|
|
1915f91526 | ||
|
|
9bdd0b1a60 | ||
|
|
d539eee90b | ||
|
|
d010ed2301 | ||
|
|
e9f89bbebf | ||
|
|
d640eea32a | ||
|
|
375ca12b05 | ||
|
|
28f9b1948b | ||
|
|
4f5adb4fae | ||
|
|
4ff3f3eb5d | ||
|
|
8b89b65f25 | ||
|
|
bc3522aabb | ||
|
|
43b86e46da | ||
|
|
ff9ef0883b | ||
|
|
26df61f638 | ||
|
|
c0c0fdb421 | ||
|
|
09f088e8a7 | ||
|
|
f353f55212 | ||
|
|
b179e34f58 | ||
|
|
3d837cdd77 | ||
|
|
a6787cfc03 | ||
|
|
45ea61261e | ||
|
|
f64b49cf55 | ||
|
|
2604c3e3c6 | ||
|
|
f3d7ab31e2 | ||
|
|
c86c7d1a79 | ||
|
|
12065bc0c7 | ||
|
|
da3edbf729 | ||
|
|
1223476280 | ||
|
|
29ac3af002 | ||
|
|
5d479491f8 | ||
|
|
985c047a4c | ||
|
|
a5344b7c85 | ||
|
|
5a2ccb657c | ||
|
|
13dac2d89c | ||
|
|
50e901f807 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+16
-16
@@ -14,9 +14,10 @@ updates:
|
||||
# single bumps: patch and minor updates travel together and pass the
|
||||
# same checks once. Majors stay single so each gets its own review.
|
||||
#
|
||||
# The Next.js line travels alone: while a 16.4 patch is still being
|
||||
# re-measured, a red Next bump inside the group holds every harmless
|
||||
# update in it red as well.
|
||||
# The Next.js line travels alone. Its bumps are the ones that move
|
||||
# URL state after history traversal (see the note on 16.4 below), and a
|
||||
# red Next bump inside the group would hold every harmless update in
|
||||
# it red as well.
|
||||
production-weekly:
|
||||
dependency-type: "production"
|
||||
update-types:
|
||||
@@ -32,19 +33,18 @@ updates:
|
||||
exclude-patterns:
|
||||
- "eslint-config-next"
|
||||
- "@next/bundle-analyzer"
|
||||
ignore:
|
||||
# Next 16.4.0 breaks URL state after history traversal: with a sheet
|
||||
# opened through `?doc=`, Back leaves it open, and `[` / `]` sometimes
|
||||
# do not move `?day=`. Reproduced in three end-to-end journeys (3 of 40
|
||||
# repeated runs on 16.4.0, 0 of 80 on 16.3.8 with everything else
|
||||
# equal). 16.3.8 carries every published security fix, so the line
|
||||
# waits for a 16.4 patch; the two packages that move with it wait too.
|
||||
- dependency-name: "next"
|
||||
versions: ["16.4.0"]
|
||||
- dependency-name: "eslint-config-next"
|
||||
versions: ["16.4.0"]
|
||||
- dependency-name: "@next/bundle-analyzer"
|
||||
versions: ["16.4.0"]
|
||||
# No ignore for the Next.js line any more. 16.4.0 lost URL state after
|
||||
# history traversal: with a sheet opened through `?doc=`, Back left it
|
||||
# open, and `[` / `]` sometimes did not move `?day=` (3 of 40 repeated
|
||||
# runs of three end-to-end journeys, 0 of 80 on 16.3.8). 16.4.2 does the
|
||||
# same (the day keys lost a step in 2 of 20 runs locally, the `?doc=`
|
||||
# journey failed once in CI). Both were ours to fix: the sheet followed
|
||||
# `useSearchParams`, which 16.4 updates after the write it copies, and a
|
||||
# day step wrote the URL before Next had adopted the previous step, whose
|
||||
# late adoption then put the older URL back. The sheet now follows the
|
||||
# address bar and a step waits for the layer. Measured after both fixes:
|
||||
# 200 of 200 repeated runs of the three journeys, and another 80 of 80
|
||||
# each for the `?doc=` and day-key journeys.
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
|
||||
+123
-23
@@ -23,9 +23,16 @@ jobs:
|
||||
# Two shards grew to 14–19 minutes against the 20-minute cap once the
|
||||
# mobile route sweep landed, and a slow runner tipped them over; three
|
||||
# shards with a 30-minute cap leave room for both.
|
||||
# Every Playwright job runs inside Microsoft's Playwright image, which ships
|
||||
# the browsers with their system libraries: installing them from apt at run
|
||||
# time stalled shards for 29 minutes twice, once for WebKit and once for
|
||||
# Chromium alone.
|
||||
shard:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: playwright-version
|
||||
timeout-minutes: 30
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v${{ needs.playwright-version.outputs.version }}-noble
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -38,8 +45,6 @@ jobs:
|
||||
POSTGRES_USER: healthlog
|
||||
POSTGRES_PASSWORD: healthlog
|
||||
POSTGRES_DB: healthlog
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U healthlog"
|
||||
--health-interval 5s
|
||||
@@ -47,7 +52,8 @@ jobs:
|
||||
--health-retries 10
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public
|
||||
# Inside a job container the service is reached by its name.
|
||||
DATABASE_URL: postgresql://healthlog:healthlog@postgres:5432/healthlog?schema=public
|
||||
ENCRYPTION_KEYS: '{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}'
|
||||
ENCRYPTION_ACTIVE_KEY_ID: v1
|
||||
API_TOKEN_HMAC_KEY: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
|
||||
@@ -56,10 +62,16 @@ jobs:
|
||||
AUTH_RP_ORIGIN: http://localhost:3000
|
||||
SESSION_COOKIE_SECURE: "false"
|
||||
NODE_ENV: production
|
||||
# The WebKit spec runs in the `webkit` job below.
|
||||
E2E_SKIP_WEBKIT: "1"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# The image runs as root while the checkout belongs to the runner user;
|
||||
# git refuses such a tree, and the prepare hook calls git.
|
||||
- run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
@@ -73,16 +85,6 @@ jobs:
|
||||
|
||||
- run: pnpm db:migrate:deploy
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
playwright-${{ runner.os }}-
|
||||
|
||||
- run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
# `pnpm build` (not bare `next build`): the prebuild hook generates
|
||||
# the versioned service-worker stamp AND the static i18n catalog
|
||||
# assets (public/i18n/<locale>.js) the client boots from — without
|
||||
@@ -93,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
|
||||
@@ -124,19 +127,116 @@ jobs:
|
||||
retention-days: 7
|
||||
if-no-files-found: error
|
||||
|
||||
# The Playwright image must carry the browsers of the exact Playwright the
|
||||
# lockfile resolves, or the run refuses them. Read the version from the
|
||||
# lockfile so a dependency bump moves the image with it.
|
||||
playwright-version:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
version: ${{ steps.read.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: pnpm-lock.yaml
|
||||
sparse-checkout-cone-mode: false
|
||||
- id: read
|
||||
run: |
|
||||
version=$(grep -m1 -E "^ playwright-core@[0-9]" pnpm-lock.yaml | sed -E "s/^ playwright-core@([0-9.]+):.*/\1/")
|
||||
if [ -z "$version" ]; then echo "playwright-core not found in pnpm-lock.yaml"; exit 1; fi
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The WebKit spec (`bottom-sheet.spec.ts`, project `webkit-iphone`), in the
|
||||
# same image as the shards.
|
||||
webkit:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: playwright-version
|
||||
timeout-minutes: 30
|
||||
name: e2e webkit
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v${{ needs.playwright-version.outputs.version }}-noble
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: healthlog
|
||||
POSTGRES_PASSWORD: healthlog
|
||||
POSTGRES_DB: healthlog
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U healthlog"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
|
||||
env:
|
||||
# Inside a job container the service is reached by its name.
|
||||
DATABASE_URL: postgresql://healthlog:healthlog@postgres:5432/healthlog?schema=public
|
||||
ENCRYPTION_KEYS: '{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}'
|
||||
ENCRYPTION_ACTIVE_KEY_ID: v1
|
||||
API_TOKEN_HMAC_KEY: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
|
||||
AUTH_RP_NAME: HealthLog
|
||||
AUTH_RP_ID: localhost
|
||||
AUTH_RP_ORIGIN: http://localhost:3000
|
||||
SESSION_COOKIE_SECURE: "false"
|
||||
NODE_ENV: production
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# The image runs as root while the checkout belongs to the runner user;
|
||||
# git refuses such a tree, and the prepare hook calls git.
|
||||
- run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
|
||||
- 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
|
||||
|
||||
- run: pnpm db:migrate:deploy
|
||||
|
||||
- run: NODE_OPTIONS=--max-old-space-size=8192 pnpm build
|
||||
|
||||
- run: pnpm exec playwright test --project=webkit-iphone
|
||||
env:
|
||||
E2E_BASE_URL: http://localhost:3000
|
||||
|
||||
- name: Upload Playwright HTML report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: playwright-report-webkit
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Upload Playwright JSON report
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: playwright-json-shard-webkit
|
||||
path: playwright-json/results.json
|
||||
retention-days: 7
|
||||
if-no-files-found: error
|
||||
|
||||
# Branch protection requires a check named exactly "e2e". This job carries
|
||||
# that name and answers for both shards: it fails when any shard failed or
|
||||
# was cancelled, so a half-green matrix cannot satisfy the gate.
|
||||
# that name and answers for every shard and the WebKit job: it fails when
|
||||
# any of them failed or was cancelled, so a half-green run cannot satisfy
|
||||
# the gate.
|
||||
e2e:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: shard
|
||||
needs: [shard, webkit]
|
||||
if: always()
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Every shard finished green
|
||||
run: |
|
||||
if [ "${{ needs.shard.result }}" != "success" ]; then
|
||||
echo "shard result: ${{ needs.shard.result }}"
|
||||
if [ "${{ needs.shard.result }}" != "success" ] || [ "${{ needs.webkit.result }}" != "success" ]; then
|
||||
echo "shard result: ${{ needs.shard.result }}, webkit result: ${{ needs.webkit.result }}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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$''',
|
||||
]
|
||||
|
||||
@@ -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
|
||||
+173
@@ -1,5 +1,178 @@
|
||||
# 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
|
||||
|
||||
- **Sheets that come up from the bottom can be pulled.** On a phone every
|
||||
sheet now follows your finger: pull it down to close it, flick it away,
|
||||
or let go early and it springs back. Content that is scrolled scrolls
|
||||
first, sideways gestures such as sliders keep working, and the X, Escape
|
||||
and a tap outside still close it. The day view opens at half height as
|
||||
before and pulls up to almost the full screen. The page behind no longer
|
||||
reloads when you pull inside a sheet, and closing a sheet puts the focus
|
||||
back where you were.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Your own targets shape the dashboard charts.** Blood pressure, resting
|
||||
pulse and body fat drew the standard band on the dashboard even when you
|
||||
had set your own targets; they now draw yours, and the tile colours follow
|
||||
them. The oxygen saturation page uses your target too. Without your own
|
||||
targets nothing changes. (#1261)
|
||||
- **Local thinking models finish the briefing, and regenerate waits for
|
||||
them.** A thinking model such as Gemma 4 on Ollama could spend a long
|
||||
briefing thinking and hand back no answer, which was logged as a
|
||||
connection problem and shown as a timeout. A reply with nothing but
|
||||
thinking is now asked once more without thinking, and that model is asked
|
||||
that way for the next hour, through the Local provider and an
|
||||
OpenAI-compatible gateway alike. The regenerate button waits as long as
|
||||
your AI response timeout allows instead of giving up after 45 seconds, and
|
||||
so does saving your notes about yourself; a local model allowed more than
|
||||
five minutes is no longer cut off at five. A failed call is logged with
|
||||
what actually went wrong, such as a refused or dropped connection, an
|
||||
empty reply or a timeout, and the briefing only suggests a longer timeout
|
||||
when time really ran out. The provider's reasoning setting now says that
|
||||
the daily briefing thinks at its own depth. (#1267)
|
||||
|
||||
### Thanks
|
||||
|
||||
Thanks to @mills1975 for spotting that the targets never reached the chart
|
||||
(#1261).
|
||||
|
||||
Thanks to @Tio61 for the precise report that traced the regenerate timeout
|
||||
to its line and ruled out the transport with their own requests from inside
|
||||
the container (#1267).
|
||||
|
||||
## [1.44.1] — 2026-10-22
|
||||
|
||||
### Security
|
||||
|
||||
- **Next.js 16.4.2.** The framework fixed two critical advisories that
|
||||
allow unauthenticated remote code execution, one in `use cache` functions
|
||||
that return typed arrays (GHSA-673q-mjm6-r5xr) and one in the image
|
||||
optimizer when it handles SVG (GHSA-2464-fhr4-w9xw), and a high-severity
|
||||
denial of service through Server Actions (GHSA-p3m5-59mp-jv5r). HealthLog
|
||||
uses neither `use cache` nor the image component, and it defines no Server
|
||||
Actions and refuses requests that call one. Every instance runs the same
|
||||
framework, though, so it updates anyway.
|
||||
- **The image optimizer is off.** Nothing in HealthLog renders through it,
|
||||
so `/_next/image` now answers 404 instead of processing whatever image a
|
||||
request names. Icons and logos load as before.
|
||||
- **nodemailer 10.1.7** fixes several advisories in how email addresses are
|
||||
parsed (GHSA-8c8m-p3m8-cjg8 among them).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`[` and `]` move the open day on every press.** Pressed quickly in a
|
||||
row, one press could be lost and the day ended one short. A step now
|
||||
starts from the day in the address bar and waits until the previous one
|
||||
is shown.
|
||||
- **Back closes a document opened from the vault.** Pressed right after
|
||||
opening a document, Back could leave its sheet open. The sheet now follows
|
||||
the address bar directly.
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
- None. Pull the new image and restart.
|
||||
|
||||
## [1.44.0] — 2026-10-22
|
||||
|
||||
### Added
|
||||
|
||||
+27
-7
@@ -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
@@ -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 && \
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+119
-10
@@ -1,14 +1,123 @@
|
||||
{
|
||||
"$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,
|
||||
"/topics/[topic]/page": 298,
|
||||
"/topics/page": 293,
|
||||
"/vaccinations/page": 327
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+2210
-743
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
```
|
||||
@@ -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>`.
|
||||
@@ -98,6 +98,7 @@ No `text-[9px]`. No off-scale sizes (`text-[28px]`); round to the neighbouring s
|
||||
| Mutation error inside a form/sheet | inline `role="alert"` text | `QueryErrorCard` (wrong shape) |
|
||||
| Honest no-data state | `EmptyState` | reusing error copy: a failed query must never fall through to "no data yet" |
|
||||
| Modal reachable on mobile | `ResponsiveSheet` (bottom sheet on phones) | raw `Dialog` (admin/desktop-only surfaces excepted) |
|
||||
| A sheet from the bottom edge (phones) | `<SheetContent side="bottom">` or `ResponsiveSheet`, both of which render `BottomSheetContent` (`ui/bottom-sheet.tsx`): grip, swipe to close, X on the title row. `preview="58dvh"` for a sheet that opens part-way and pulls up to 90 dvh (its footer `data-sheet-footer`, its scroll body `data-sheet-body`); `draggable={false}` only for a sheet that fills the window | a hand-rolled grab handle or drag; a `fixed bottom-0` panel standing in for a sheet |
|
||||
| Date / time entry | `DateField` / `TimeField` / `DateTimeField` | native `type="date | time | datetime-local"` inputs (browser-locale trap, v1.25.4) |
|
||||
| Small text action inside a line (Open schedule, Select all, View all, Retry, Learn more) | `InlineAction` (`ui/inline-action.tsx`): tone `primary` / `muted` / `foreground`, size `xs` / `sm`, `asChild` for a `Link`; owns the focus ring and `pointer-coarse:min-h-11` | a hand-rolled underlined `<button>` / `<Link>` (`inline-action-guard.test.ts`) |
|
||||
| Mutually exclusive choice on one track (range, zoom, cards vs table, recent vs all) | `Segmented` (`ui/segmented.tsx`): no track, the chosen segment filled in primary (the chart tabs' look; a muted track with a raised segment was tried in 1.43.8 and dropped in 1.43.10 as too heavy); `mode="radio"` (default, roving focus) or `"toggle"` (view switches, form sheets), `iconOnly` for icon segments, `size="sm"` for range rows in a chart header; 44 px per segment on a coarse pointer | a row of ghost/secondary Buttons, a fifth segmented variant (`segmented.test.tsx` holds the definition to one module) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
+47
-1
@@ -2,7 +2,10 @@ import AxeBuilder from "@axe-core/playwright";
|
||||
import type { Locator, Page, Route } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import {
|
||||
MOBILE_ROUTES_STORAGE_STATE_PATH,
|
||||
STORAGE_STATE_PATH,
|
||||
} from "./setup/global-setup";
|
||||
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
import { openMenu } from "./open-menu";
|
||||
import { POPULATED_SUMMARIES } from "./utils/mock-dashboard-snapshot";
|
||||
@@ -979,6 +982,49 @@ const ADMIN_AND_BASELINE_ROUTES: readonly RouteCase[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The topic pages (preview), on the phone-sweep account: its preview is on
|
||||
* and never switched, and its record holds a little of every kind, so the
|
||||
* overview draws tiles and the pages draw both their data and their
|
||||
* below-threshold states. Gated on the grid and the page body, which live in
|
||||
* other components than the `<h1>` the scan checks.
|
||||
*/
|
||||
const TOPIC_ROUTES: readonly RouteCase[] = [
|
||||
{
|
||||
name: "/topics overview",
|
||||
path: "/topics",
|
||||
painted: (page) => page.locator('[data-slot="topics-grid"]:visible'),
|
||||
},
|
||||
{
|
||||
name: "/topics/heart",
|
||||
path: "/topics/heart",
|
||||
painted: (page) => page.locator('[data-slot="topic-detail-body"]:visible'),
|
||||
},
|
||||
{
|
||||
name: "/topics/metabolism",
|
||||
path: "/topics/metabolism",
|
||||
painted: (page) => page.locator('[data-slot="topic-detail-body"]:visible'),
|
||||
},
|
||||
];
|
||||
|
||||
test.describe("axe-core topic pages (preview)", () => {
|
||||
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
|
||||
|
||||
for (const theme of THEMES) {
|
||||
test(`theme=${theme} routes: /topics, /topics/heart, /topics/metabolism`, async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await useExplicitTheme(page, theme);
|
||||
const blocking: AxeViolation[] = [];
|
||||
for (const routeCase of TOPIC_ROUTES) {
|
||||
blocking.push(...(await visitAndScan(page, theme, routeCase)));
|
||||
}
|
||||
expectNoViolations(theme, "topic route matrix", blocking);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("axe-core public surfaces", () => {
|
||||
for (const theme of THEMES) {
|
||||
test(`theme=${theme} /auth/login`, async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
|
||||
import { mockDay } from "./setup/day-mock";
|
||||
import { expect, test } from "./setup/test";
|
||||
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* The bottom sheet's drag on WebKit with an iPhone profile (the
|
||||
* `webkit-iphone` project), against the three kinds of bottom sheet the app
|
||||
* has: the More sheet (a plain `<Sheet side="bottom">`), the day sheet (the
|
||||
* preview step) and a capture form (`ResponsiveSheet` on a phone).
|
||||
*
|
||||
* Playwright can tap on WebKit but not move a touch, so a drag is dispatched
|
||||
* through the page as the browser would deliver it: `pointerdown` /
|
||||
* `pointermove` / `pointerup` with `pointerType: "touch"` and the matching
|
||||
* `touchstart` / `touchmove` / `touchend`, on the element under the finger,
|
||||
* paced in real time so the release speed is real. Nothing calls into the
|
||||
* sheet's own code. What synthetic events cannot do is scroll natively, so
|
||||
* "content scrolls first" is proven as "a scrolled body does not move the
|
||||
* sheet", with the scroll position set the way a scroll would leave it.
|
||||
*
|
||||
* Stable hooks only: `bottom-nav-more` / `bottom-nav-more-sheet`,
|
||||
* `day-panel` with `data-sheet-snap`, `day-body`, `day-footer`,
|
||||
* `sheet-grip`, `sheet-close-button`, `sheet-overlay`, `capture-picker-*`.
|
||||
*/
|
||||
|
||||
interface Drag {
|
||||
dx?: number;
|
||||
dy: number;
|
||||
/** Duration of the movement, ms. */
|
||||
ms: number;
|
||||
steps?: number;
|
||||
/** Rest before lifting the finger, ms (a slow, deliberate release). */
|
||||
holdMs?: number;
|
||||
/**
|
||||
* After the rest, keep the finger down until the page has rendered this
|
||||
* many frames. The sheet writes its position once per animation frame, and
|
||||
* WebKit in the CI container can go a second or more without rendering a
|
||||
* frame while timers and events run on (the trace shows the More sheet
|
||||
* still sliding in 1.5 s after its animation reported finished). A check
|
||||
* on where the sheet was drawn needs those frames; a check on where it
|
||||
* ends up does not, because the release is decided from the touch alone.
|
||||
*/
|
||||
holdFrames?: number;
|
||||
}
|
||||
|
||||
/** A touch drag from (x, y), dispatched as WebKit delivers one. */
|
||||
async function touchDrag(page: Page, x: number, y: number, drag: Drag) {
|
||||
await page.evaluate(
|
||||
async ({ x, y, dx, dy, ms, steps, holdMs, holdFrames }) => {
|
||||
const target = document.elementFromPoint(x, y);
|
||||
if (!target) throw new Error(`nothing under ${x},${y}`);
|
||||
const id = 11;
|
||||
const touchAt = (cx: number, cy: number) => {
|
||||
const init = {
|
||||
identifier: id,
|
||||
target,
|
||||
clientX: cx,
|
||||
clientY: cy,
|
||||
pageX: cx,
|
||||
pageY: cy,
|
||||
screenX: cx,
|
||||
screenY: cy,
|
||||
radiusX: 4,
|
||||
radiusY: 4,
|
||||
force: 1,
|
||||
};
|
||||
try {
|
||||
return new Touch(init);
|
||||
} catch {
|
||||
return init as unknown as Touch;
|
||||
}
|
||||
};
|
||||
const touchEvent = (type: string, cx: number, cy: number) => {
|
||||
const touch = touchAt(cx, cy);
|
||||
const touches = type === "touchend" ? [] : [touch];
|
||||
try {
|
||||
return new TouchEvent(type, {
|
||||
touches,
|
||||
targetTouches: touches,
|
||||
changedTouches: [touch],
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
});
|
||||
} catch {
|
||||
const event = new Event(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
});
|
||||
Object.defineProperty(event, "touches", { value: touches });
|
||||
Object.defineProperty(event, "targetTouches", { value: touches });
|
||||
Object.defineProperty(event, "changedTouches", { value: [touch] });
|
||||
return event;
|
||||
}
|
||||
};
|
||||
const fire = (
|
||||
pointer: "pointerdown" | "pointermove" | "pointerup",
|
||||
cx: number,
|
||||
cy: number,
|
||||
) => {
|
||||
target.dispatchEvent(
|
||||
new PointerEvent(pointer, {
|
||||
pointerId: id,
|
||||
pointerType: "touch",
|
||||
isPrimary: true,
|
||||
clientX: cx,
|
||||
clientY: cy,
|
||||
buttons: pointer === "pointerup" ? 0 : 1,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
const touch = {
|
||||
pointerdown: "touchstart",
|
||||
pointermove: "touchmove",
|
||||
pointerup: "touchend",
|
||||
}[pointer];
|
||||
target.dispatchEvent(touchEvent(touch, cx, cy));
|
||||
};
|
||||
const wait = (t: number) => new Promise((r) => setTimeout(r, t));
|
||||
fire("pointerdown", x, y);
|
||||
for (let i = 1; i <= steps; i += 1) {
|
||||
await wait(ms / steps);
|
||||
fire("pointermove", x + (dx * i) / steps, y + (dy * i) / steps);
|
||||
}
|
||||
if (holdMs) await wait(holdMs);
|
||||
for (let i = 0; i < holdFrames; i += 1) {
|
||||
await new Promise((r) => requestAnimationFrame(() => r(null)));
|
||||
}
|
||||
fire("pointerup", x + dx, y + dy);
|
||||
},
|
||||
{
|
||||
x,
|
||||
y,
|
||||
dx: drag.dx ?? 0,
|
||||
dy: drag.dy,
|
||||
ms: drag.ms,
|
||||
steps: drag.steps ?? Math.max(4, Math.round(drag.ms / 16)),
|
||||
holdMs: drag.holdMs ?? 0,
|
||||
holdFrames: drag.holdFrames ?? 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser's pointer capture for a dispatched pointer. A real touch is
|
||||
* captured by the browser; a dispatched one is not, and Radix's slider only
|
||||
* follows a pointer it holds. This gives it the same bookkeeping, nothing
|
||||
* more: events still go to the element they are dispatched on.
|
||||
*/
|
||||
async function emulateCapture(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
const held = new WeakMap<Element, Set<number>>();
|
||||
Element.prototype.setPointerCapture = function (id: number) {
|
||||
const set = held.get(this) ?? new Set<number>();
|
||||
set.add(id);
|
||||
held.set(this, set);
|
||||
};
|
||||
Element.prototype.hasPointerCapture = function (id: number) {
|
||||
return held.get(this)?.has(id) ?? false;
|
||||
};
|
||||
Element.prototype.releasePointerCapture = function (id: number) {
|
||||
held.get(this)?.delete(id);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function centre(locator: Locator) {
|
||||
const box = await locator.boundingBox();
|
||||
if (!box) throw new Error("not on screen");
|
||||
return { x: box.x + box.width / 2, y: box.y + box.height / 2, box };
|
||||
}
|
||||
|
||||
async function topOf(locator: Locator) {
|
||||
return (await locator.boundingBox())?.y ?? NaN;
|
||||
}
|
||||
|
||||
/** Waits for the sheet to come to rest (open slide or settle finished). */
|
||||
async function settled(locator: Locator) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
locator.evaluate((el) =>
|
||||
el.getAnimations().every((a) => a.playState !== "running"),
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
// A finished animation is not a drawn one: WebKit can report the slide
|
||||
// done while it has not rendered a frame of it yet. Two frames, then rest.
|
||||
await locator
|
||||
.page()
|
||||
.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
),
|
||||
);
|
||||
await locator.page().waitForTimeout(300);
|
||||
}
|
||||
|
||||
const more = (page: Page) =>
|
||||
page.locator('[data-testid="bottom-nav-more-sheet"]');
|
||||
|
||||
async function openMore(page: Page) {
|
||||
await page.locator('[data-testid="bottom-nav-more"]').tap();
|
||||
await expect(more(page)).toBeVisible();
|
||||
await settled(more(page));
|
||||
}
|
||||
|
||||
function isoDaysAgo(days: number): string {
|
||||
const d = new Date(Date.now() - days * 86_400_000);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
test.describe("the bottom sheet", () => {
|
||||
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async ({ page, context }, testInfo) => {
|
||||
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
|
||||
await context.addCookies([
|
||||
{ name: "healthlog-locale", value: "en", url: baseURL },
|
||||
]);
|
||||
await mockDay(page);
|
||||
});
|
||||
|
||||
test("a slow drag past a quarter of its height closes it", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/mood");
|
||||
await openMore(page);
|
||||
const title = more(page).getByRole("heading").first();
|
||||
const { x, y } = await centre(title);
|
||||
const { box } = await centre(more(page));
|
||||
await touchDrag(page, x, y, {
|
||||
dy: box.height * 0.4,
|
||||
ms: 700,
|
||||
holdMs: 160,
|
||||
});
|
||||
await expect(more(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a short drag springs back", async ({ page }) => {
|
||||
await page.goto("/mood");
|
||||
await openMore(page);
|
||||
await expect(more(page)).toBeVisible();
|
||||
const before = await topOf(more(page));
|
||||
const { x, y } = await centre(more(page).getByRole("heading").first());
|
||||
// Dragging: the sheet follows the finger and the backdrop lightens. A
|
||||
// frame loop records the furthest the sheet travelled and the lightest
|
||||
// the backdrop got for the whole gesture, so a slow runner cannot make
|
||||
// a single fixed-time sample land before the drag has begun.
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as {
|
||||
__sheetDrag?: { moved: number; opacity: number; on: boolean };
|
||||
};
|
||||
w.__sheetDrag = { moved: 0, opacity: 1, on: true };
|
||||
const tick = () => {
|
||||
const rec = w.__sheetDrag!;
|
||||
if (!rec.on) return;
|
||||
const sheet = document.querySelector(
|
||||
'[data-testid="bottom-nav-more-sheet"]',
|
||||
) as HTMLElement | null;
|
||||
const overlay = document.querySelector(
|
||||
'[data-slot="sheet-overlay"]',
|
||||
) as HTMLElement | null;
|
||||
if (sheet) {
|
||||
const moved = new DOMMatrix(getComputedStyle(sheet).transform).f;
|
||||
rec.moved = Math.max(rec.moved, moved);
|
||||
}
|
||||
if (overlay) {
|
||||
rec.opacity = Math.min(
|
||||
rec.opacity,
|
||||
Number(getComputedStyle(overlay).opacity),
|
||||
);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
});
|
||||
// Two rendered frames with the finger still down: the first paints the
|
||||
// sheet where the finger is, the second lets the recorder read it.
|
||||
await touchDrag(page, x, y, {
|
||||
dy: 60,
|
||||
ms: 300,
|
||||
holdMs: 200,
|
||||
holdFrames: 2,
|
||||
});
|
||||
const seen = await page.evaluate(() => {
|
||||
const w = window as unknown as {
|
||||
__sheetDrag: { moved: number; opacity: number; on: boolean };
|
||||
};
|
||||
w.__sheetDrag.on = false;
|
||||
return { moved: w.__sheetDrag.moved, opacity: w.__sheetDrag.opacity };
|
||||
});
|
||||
expect(seen.moved).toBeGreaterThan(40);
|
||||
expect(seen.opacity).toBeLessThan(1);
|
||||
await settled(more(page));
|
||||
await expect(more(page)).toBeVisible();
|
||||
expect(Math.abs((await topOf(more(page))) - before)).toBeLessThan(1);
|
||||
});
|
||||
|
||||
test("a fast flick closes it", async ({ page }) => {
|
||||
await page.goto("/mood");
|
||||
await openMore(page);
|
||||
const { x, y } = await centre(more(page).getByRole("heading").first());
|
||||
const { box } = await centre(more(page));
|
||||
// Well short of a quarter of the height, but fast.
|
||||
await touchDrag(page, x, y, {
|
||||
dy: Math.min(70, box.height * 0.2),
|
||||
ms: 40,
|
||||
steps: 4,
|
||||
});
|
||||
await expect(more(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("the backdrop, the X and Escape close it; focus goes back to the trigger", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/mood");
|
||||
const trigger = page.locator('[data-testid="bottom-nav-more"]');
|
||||
|
||||
await openMore(page);
|
||||
await page.touchscreen.tap(195, 40);
|
||||
await expect(more(page)).toHaveCount(0);
|
||||
|
||||
await openMore(page);
|
||||
const close = more(page).locator('[data-slot="sheet-close-button"]');
|
||||
const closeBox = (await close.boundingBox())!;
|
||||
expect(closeBox.width).toBeGreaterThanOrEqual(44);
|
||||
expect(closeBox.height).toBeGreaterThanOrEqual(44);
|
||||
await close.tap();
|
||||
await expect(more(page)).toHaveCount(0);
|
||||
|
||||
// The keyboard path: open from the focused trigger, close with Escape.
|
||||
await trigger.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(more(page)).toBeVisible();
|
||||
await expect(more(page)).toHaveAttribute("role", "dialog");
|
||||
await expect(more(page)).toHaveAttribute("aria-modal", "true");
|
||||
await expect(more(page)).toHaveAccessibleName(/more/i);
|
||||
await expect
|
||||
.poll(() =>
|
||||
more(page).evaluate((el) => el.contains(document.activeElement)),
|
||||
)
|
||||
.toBe(true);
|
||||
// Tab stays inside.
|
||||
for (let i = 0; i < 14; i += 1) await page.keyboard.press("Tab");
|
||||
expect(
|
||||
await more(page).evaluate((el) => el.contains(document.activeElement)),
|
||||
).toBe(true);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(more(page)).toHaveCount(0);
|
||||
await expect(trigger).toBeFocused();
|
||||
});
|
||||
|
||||
test("the page behind keeps its scroll position and is never pulled to refresh", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
const main = page.locator("#main-content");
|
||||
await expect(main).toBeVisible();
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// Watch for the refresh disc for the rest of the test.
|
||||
await page.evaluate(() => {
|
||||
(window as unknown as { ptrSeen: number }).ptrSeen = 0;
|
||||
new MutationObserver(() => {
|
||||
if (document.querySelector('[data-slot="pull-to-refresh-indicator"]')) {
|
||||
(window as unknown as { ptrSeen: number }).ptrSeen += 1;
|
||||
}
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
});
|
||||
const ptrSeen = () =>
|
||||
page.evaluate(() => (window as unknown as { ptrSeen: number }).ptrSeen);
|
||||
|
||||
// The control: the same pull on the page itself does arm the disc, so
|
||||
// the watch above can see it (short of the refresh threshold).
|
||||
await touchDrag(page, 195, 320, { dy: 90, ms: 400, holdMs: 120 });
|
||||
await expect.poll(ptrSeen).toBeGreaterThan(0);
|
||||
await page.waitForTimeout(300);
|
||||
await page.evaluate(() => {
|
||||
(window as unknown as { ptrSeen: number }).ptrSeen = 0;
|
||||
});
|
||||
|
||||
// A pull on the sheet, short (it springs back) and long (it closes),
|
||||
// from the dashboard at the top: the page behind never takes it.
|
||||
await openMore(page);
|
||||
const { x, y } = await centre(more(page).getByRole("heading").first());
|
||||
await touchDrag(page, x, y, { dy: 60, ms: 400, holdMs: 150 });
|
||||
await settled(more(page));
|
||||
const { box } = await centre(more(page));
|
||||
await touchDrag(page, x, y, { dy: box.height * 0.5, ms: 600, holdMs: 150 });
|
||||
await expect(more(page)).toHaveCount(0);
|
||||
expect(await ptrSeen()).toBe(0);
|
||||
|
||||
// Scrolled down, the page is where it was after the sheet has gone.
|
||||
await main.evaluate((el) => el.scrollTo({ top: 400 }));
|
||||
const before = await main.evaluate((el) => el.scrollTop);
|
||||
expect(before).toBeGreaterThan(0);
|
||||
await openMore(page);
|
||||
const at = await centre(more(page).getByRole("heading").first());
|
||||
const sheetBox = (await more(page).boundingBox())!;
|
||||
await touchDrag(page, at.x, at.y, {
|
||||
dy: sheetBox.height * 0.5,
|
||||
ms: 600,
|
||||
holdMs: 150,
|
||||
});
|
||||
await expect(more(page)).toHaveCount(0);
|
||||
expect(await main.evaluate((el) => el.scrollTop)).toBe(before);
|
||||
expect(await ptrSeen()).toBe(0);
|
||||
});
|
||||
|
||||
test("the day opens on its preview step, rises on a pull up, and its content scrolls before it drags", async ({
|
||||
page,
|
||||
}) => {
|
||||
// A short window, so the day's content is longer than its sheet on
|
||||
// both steps and the body really scrolls.
|
||||
await page.setViewportSize({ width: 390, height: 480 });
|
||||
await page.goto(`/mood?day=${isoDaysAgo(3)}`);
|
||||
const panel = page.locator('[data-slot="day-panel"]');
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel).toHaveAttribute("data-shell", "bottom");
|
||||
await expect(panel).toHaveAttribute("data-sheet-snap", "preview");
|
||||
await settled(panel);
|
||||
const viewport = page.viewportSize()!;
|
||||
|
||||
// On the preview step the footer stands on the window's bottom edge.
|
||||
const footer = panel.locator('[data-slot="day-footer"]');
|
||||
const footerBox = (await footer.boundingBox())!;
|
||||
expect(
|
||||
Math.abs(footerBox.y + footerBox.height - viewport.height),
|
||||
).toBeLessThan(2);
|
||||
|
||||
// A pull up on the content raises it to the full 90 dvh.
|
||||
const body = panel.locator('[data-slot="day-body"]');
|
||||
const b = await centre(body);
|
||||
await touchDrag(page, b.x, b.y, { dy: -180, ms: 500, holdMs: 150 });
|
||||
await expect(panel).toHaveAttribute("data-sheet-snap", "full");
|
||||
await settled(panel);
|
||||
expect(await topOf(panel)).toBeCloseTo(viewport.height * 0.1, -1);
|
||||
|
||||
// Scrolled content: a pull down scrolls (the browser's), the sheet stays.
|
||||
const scrollable = await body.evaluate(
|
||||
(el) => el.scrollHeight > el.clientHeight,
|
||||
);
|
||||
expect(scrollable).toBe(true);
|
||||
await body.evaluate((el) => el.scrollTo({ top: 60 }));
|
||||
const top = await topOf(panel);
|
||||
const inner = await centre(body);
|
||||
await touchDrag(page, inner.x, inner.y, {
|
||||
dy: 260,
|
||||
ms: 500,
|
||||
holdMs: 150,
|
||||
});
|
||||
await settled(panel);
|
||||
await expect(panel).toHaveAttribute("data-sheet-snap", "full");
|
||||
expect(Math.abs((await topOf(panel)) - top)).toBeLessThan(1);
|
||||
|
||||
// At its very top the same pull drags the sheet: down to the preview
|
||||
// step, and from there out.
|
||||
await body.evaluate((el) => el.scrollTo({ top: 0 }));
|
||||
await touchDrag(page, inner.x, inner.y - 60, {
|
||||
dy: 150,
|
||||
ms: 500,
|
||||
holdMs: 150,
|
||||
});
|
||||
await expect(panel).toHaveAttribute("data-sheet-snap", "preview");
|
||||
await settled(panel);
|
||||
const grip = panel.locator('[data-slot="sheet-grip"]');
|
||||
const g = await centre(grip);
|
||||
await touchDrag(page, g.x, g.y, { dy: 300, ms: 600, holdMs: 150 });
|
||||
await expect(panel).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/mood$/);
|
||||
});
|
||||
|
||||
test("the grip is a button that switches the day's two heights", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/mood?day=${isoDaysAgo(3)}`);
|
||||
const panel = page.locator('[data-slot="day-panel"]');
|
||||
await expect(panel).toHaveAttribute("data-sheet-snap", "preview");
|
||||
const grip = panel.locator('[data-slot="sheet-grip"]');
|
||||
await expect(grip).toHaveAttribute("aria-expanded", "false");
|
||||
await grip.tap();
|
||||
await expect(panel).toHaveAttribute("data-sheet-snap", "full");
|
||||
await expect(grip).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
test("a slider inside a sheet moves sideways and the sheet stays", async ({
|
||||
page,
|
||||
}) => {
|
||||
await emulateCapture(page);
|
||||
await page.goto("/mood");
|
||||
await page.locator('[data-testid="bottom-nav-capture"]').tap();
|
||||
await page.locator('[data-testid="capture-picker-symptom"]').tap();
|
||||
const form = page.locator(
|
||||
'[data-slot="responsive-sheet-content"][data-state="open"]',
|
||||
);
|
||||
await expect(form).toBeVisible();
|
||||
await settled(form);
|
||||
const slider = form.locator('[data-slot="slider"]').first();
|
||||
await slider.scrollIntoViewIfNeeded();
|
||||
await settled(form);
|
||||
const top = await topOf(form);
|
||||
const s = await centre(slider);
|
||||
const thumb = form.locator('[role="slider"]').first();
|
||||
const valueOf = async () =>
|
||||
(await thumb.count()) ? thumb.getAttribute("aria-valuenow") : null;
|
||||
const before = await valueOf();
|
||||
// A sideways drag with a little vertical wobble, across the track.
|
||||
await touchDrag(page, s.box.x + 8, s.y, {
|
||||
dx: s.box.width * 0.7,
|
||||
dy: 6,
|
||||
ms: 400,
|
||||
});
|
||||
await expect.poll(valueOf).not.toBe(before);
|
||||
await settled(form);
|
||||
await expect(form).toBeVisible();
|
||||
expect(Math.abs((await topOf(form)) - top)).toBeLessThan(1);
|
||||
});
|
||||
|
||||
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();
|
||||
const atInsert = await page.evaluate(
|
||||
() =>
|
||||
(window as unknown as { __sheetAtInsert?: unknown }).__sheetAtInsert,
|
||||
);
|
||||
expect(atInsert).toEqual([
|
||||
{ name: "none", animations: 0 },
|
||||
{ name: "none", animations: 0 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -49,8 +49,12 @@ async function openFromPlot(page: Page, plot: Locator, testInfo: TestInfo) {
|
||||
await expect(open).toBeVisible();
|
||||
// A tap alone never opens a sheet: a point is a small target.
|
||||
await expect(panel(page)).toHaveCount(0);
|
||||
// The button is a calm, full 44 px row.
|
||||
expect((await open.boundingBox())!.height).toBeGreaterThanOrEqual(44);
|
||||
// The button is a calm, full 44 px row. The tooltip re-renders while the
|
||||
// tap settles, so the measurement retries instead of reading a box from
|
||||
// a node that has just been replaced.
|
||||
await expect
|
||||
.poll(async () => (await open.boundingBox())?.height ?? 0)
|
||||
.toBeGreaterThanOrEqual(44);
|
||||
await open.tap();
|
||||
await expect(panel(page)).toHaveAttribute("data-shell", "bottom");
|
||||
} else {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -5,7 +5,8 @@ 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 { TOPIC_KEYS } from "@/lib/topics/registry";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
@@ -172,6 +173,9 @@ const DYNAMIC_ROUTES: Record<string, Resolver> = {
|
||||
)}`,
|
||||
];
|
||||
},
|
||||
// Every topic (preview). The sweep account has the preview on; a topic its
|
||||
// modules do not offer still renders its frame and the refusal card.
|
||||
"/topics/[topic]": async () => TOPIC_KEYS.map((topic) => `/topics/${topic}`),
|
||||
// An ECG strip only arrives through a device sync; no route writes one. The
|
||||
// detail page is swept in its not-found state, which is still the frame,
|
||||
// header and back link a phone renders.
|
||||
|
||||
@@ -58,6 +58,7 @@ const SETTINGS_ROUTES = [
|
||||
"/settings/api",
|
||||
"/settings/mcp",
|
||||
"/settings/advanced",
|
||||
"/settings/preview",
|
||||
"/settings/about",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -35,6 +35,7 @@ import pg from "pg";
|
||||
|
||||
import { seedDayDoorsRecord } from "./day-doors-fixture";
|
||||
import { seedMobileRoutesRecord } from "./mobile-routes-fixture";
|
||||
import { seedTopicsRecord } from "./topics-fixture";
|
||||
|
||||
// Resolve relative to the project root rather than __dirname/import.meta.url —
|
||||
// Playwright's TS loader runs files as CJS without `"type":"module"`, and
|
||||
@@ -289,6 +290,27 @@ export const SCORE_HISTORY_STORAGE_STATE_PATH = resolve(
|
||||
"e2e/setup/storageStateScoreHistory.json",
|
||||
);
|
||||
|
||||
/**
|
||||
* The topic pages' own account (`topics-preview.spec.ts`).
|
||||
*
|
||||
* The journey switches the Topics preview on and off, which adds and removes
|
||||
* a navigation entry for the whole account; on the shared account a parallel
|
||||
* spec would see its More sheet change underneath it. The preview starts off,
|
||||
* the way every account starts, and the record is written after the login
|
||||
* capture (`topics-fixture.ts`).
|
||||
*/
|
||||
export const E2E_TOPICS = {
|
||||
email: "e2e-topics@healthlog.test",
|
||||
username: "e2e-topics",
|
||||
password: "Tp8!Hq4nVw7zKc3M",
|
||||
role: "USER",
|
||||
} as const;
|
||||
|
||||
export const TOPICS_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateTopics.json",
|
||||
);
|
||||
|
||||
export const AI_OPTIONAL_STORAGE_STATE_PATH = resolve(
|
||||
process.cwd(),
|
||||
"e2e/setup/storageStateAiOptional.json",
|
||||
@@ -908,15 +930,17 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
|
||||
// The phone-width route sweep's account. Every opt-in module is on, so
|
||||
// the sweep reaches the glucose, documents, wellbeing, nutrients and
|
||||
// environment surfaces; its record is written after the login capture.
|
||||
// environment surfaces, and the Topics preview is on, so the topic pages
|
||||
// render rather than leave for Insights; its record is written after the
|
||||
// login capture.
|
||||
await pool.query(
|
||||
`INSERT INTO users
|
||||
(id, username, email, password_hash, role, created_at, updated_at,
|
||||
height_cm, date_of_birth, gender,
|
||||
onboarding_completed_at, onboarding_tour_completed,
|
||||
module_preferences_json)
|
||||
module_preferences_json, preview_features_json)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $6, 168, $7, 'FEMALE', $6, true,
|
||||
$8::jsonb)
|
||||
$8::jsonb, '{"topics": true}'::jsonb)
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
@@ -924,7 +948,8 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
|
||||
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
|
||||
module_preferences_json = EXCLUDED.module_preferences_json`,
|
||||
module_preferences_json = EXCLUDED.module_preferences_json,
|
||||
preview_features_json = EXCLUDED.preview_features_json`,
|
||||
[
|
||||
cuid(),
|
||||
E2E_MOBILE_ROUTES.username,
|
||||
@@ -990,6 +1015,31 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
],
|
||||
);
|
||||
|
||||
// The topic pages' account. Default modules, the preview off; its record
|
||||
// is written after the login capture.
|
||||
await pool.query(
|
||||
`INSERT INTO users
|
||||
(id, username, email, password_hash, role, created_at, updated_at,
|
||||
onboarding_completed_at, onboarding_tour_completed,
|
||||
module_preferences_json, preview_features_json)
|
||||
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, NULL, NULL)
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
|
||||
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
|
||||
module_preferences_json = NULL,
|
||||
preview_features_json = NULL`,
|
||||
[
|
||||
cuid(),
|
||||
E2E_TOPICS.username,
|
||||
E2E_TOPICS.email,
|
||||
await hashPassword(E2E_TOPICS.password),
|
||||
now,
|
||||
],
|
||||
);
|
||||
|
||||
// The day-doors journey's account. Every module the journey reaches is
|
||||
// on; its record is written after the login capture.
|
||||
await pool.query(
|
||||
@@ -1650,6 +1700,14 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
await capture(E2E_DAY_DOORS, DAY_DOORS_STORAGE_STATE_PATH);
|
||||
await seedDayDoorsRecord(baseURL, DAY_DOORS_STORAGE_STATE_PATH);
|
||||
|
||||
// The topic pages' jar, then its record — see `E2E_TOPICS`.
|
||||
await capture(E2E_TOPICS, TOPICS_STORAGE_STATE_PATH);
|
||||
await seedTopicsRecord(
|
||||
baseURL,
|
||||
TOPICS_STORAGE_STATE_PATH,
|
||||
E2E_TOPICS.username,
|
||||
);
|
||||
|
||||
// The score-history journey's jar — see `E2E_SCORE_HISTORY`.
|
||||
await capture(E2E_SCORE_HISTORY, SCORE_HISTORY_STORAGE_STATE_PATH);
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* The record `topics-preview.spec.ts` reads its topics from.
|
||||
*
|
||||
* Enough of each kind that every topic the account is offered has something
|
||||
* to show, and some of it deliberately short of the thresholds, so the page
|
||||
* states what is missing rather than a figure:
|
||||
*
|
||||
* - 40 days of resting heart rate, heart-rate variability and sleep, with
|
||||
* every fourth night under seven hours and the morning after it higher:
|
||||
* the heart pairs reach 14 paired days and say something;
|
||||
* - 30 days of weight, 25 of blood pressure;
|
||||
* - 12 days of steps: the steps pairs stay below 14 and say so;
|
||||
* - 10 mood entries: the mind pairs stay below 14 and say so;
|
||||
* - LDL three times over two years (a trend line) and vitamin D once
|
||||
* (below its range, no trend yet).
|
||||
*
|
||||
* The readings go straight into Postgres: the topics read the live table, and
|
||||
* a few hundred route calls would only slow `globalSetup` down. Mood entries
|
||||
* and lab results go through the app's routes, which own their encryption and
|
||||
* the marker catalogue. An account that already holds the LDL marker is taken
|
||||
* as seeded, so a local re-run against a reused database does not double it.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { request as playwrightRequest } from "@playwright/test";
|
||||
import pg from "pg";
|
||||
|
||||
export const TOPICS_LAB_MARKER = "LDL";
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
type Row = [type: string, unit: string, value: number, measuredAt: Date];
|
||||
|
||||
/** The readings, one per local morning, ending yesterday. */
|
||||
function readings(now: number): Row[] {
|
||||
const at = (daysAgo: number, hourUtc: number) => {
|
||||
const d = new Date(now - daysAgo * DAY_MS);
|
||||
d.setUTCHours(hourUtc, 30, 0, 0);
|
||||
return d;
|
||||
};
|
||||
const rows: Row[] = [];
|
||||
for (let i = 1; i <= 40; i++) {
|
||||
const shortNight = i % 4 === 0;
|
||||
rows.push(["SLEEP_DURATION", "min", shortNight ? 360 : 480, at(i, 5)]);
|
||||
rows.push([
|
||||
"RESTING_HEART_RATE",
|
||||
"bpm",
|
||||
55 + (i % 10) + (shortNight ? 5 : 0),
|
||||
at(i, 6),
|
||||
]);
|
||||
rows.push(["HEART_RATE_VARIABILITY", "ms", 80 - (i % 10) * 3, at(i, 6)]);
|
||||
}
|
||||
for (let i = 1; i <= 30; i++) {
|
||||
rows.push(["WEIGHT", "kg", 82 - (30 - i) * 0.03, at(i, 6)]);
|
||||
}
|
||||
for (let i = 1; i <= 25; i++) {
|
||||
rows.push(["BLOOD_PRESSURE_SYS", "mmHg", 118 + (i % 5), at(i, 7)]);
|
||||
rows.push(["BLOOD_PRESSURE_DIA", "mmHg", 76 + (i % 3), at(i, 7)]);
|
||||
rows.push(["PULSE", "bpm", 64 + (i % 4), at(i, 7)]);
|
||||
}
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
rows.push(["ACTIVITY_STEPS", "steps", 7000 + (i % 5) * 900, at(i, 18)]);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function seedTopicsRecord(
|
||||
baseURL: string,
|
||||
storageStatePath: string,
|
||||
username: string,
|
||||
): Promise<void> {
|
||||
const ctx = await playwrightRequest.newContext({
|
||||
baseURL,
|
||||
storageState: storageStatePath,
|
||||
});
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) throw new Error("[topics-fixture] DATABASE_URL is not set");
|
||||
const pool = new pg.Pool({ connectionString: url });
|
||||
try {
|
||||
const labs = await ctx.get("/api/labs");
|
||||
const body = (await labs.json()) as { data: unknown };
|
||||
if (JSON.stringify(body.data ?? null).includes(`"${TOPICS_LAB_MARKER}"`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await pool.query<{ id: string }>(
|
||||
"SELECT id FROM users WHERE username = $1",
|
||||
[username],
|
||||
);
|
||||
const userId = user.rows[0]?.id;
|
||||
if (!userId) throw new Error("[topics-fixture] topics account not seeded");
|
||||
|
||||
const now = Date.now();
|
||||
for (const [type, unit, value, measuredAt] of readings(now)) {
|
||||
await pool.query(
|
||||
`INSERT INTO measurements
|
||||
(id, user_id, type, value, unit, source, measured_at, updated_at)
|
||||
VALUES ($1, $2, $3::"measurement_type", $4, $5, 'MANUAL', $6, now())`,
|
||||
[randomUUID(), userId, type, value, unit, measuredAt],
|
||||
);
|
||||
}
|
||||
|
||||
const post = async (path: string, data: unknown): Promise<void> => {
|
||||
const res = await ctx.post(path, { data });
|
||||
if (res.status() === 409 || res.ok()) return;
|
||||
throw new Error(
|
||||
`[topics-fixture] ${path} answered ${res.status()}: ${(await res.text()).slice(0, 200)}`,
|
||||
);
|
||||
};
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const at = new Date(now - i * DAY_MS);
|
||||
at.setUTCHours(19, 0, 0, 0);
|
||||
await post("/api/mood-entries", {
|
||||
mood: i % 3 === 0 ? "OKAY" : "GUT",
|
||||
moodLoggedAt: at.toISOString(),
|
||||
});
|
||||
}
|
||||
for (const [analyte, unit, value, daysAgo, low, high] of [
|
||||
[TOPICS_LAB_MARKER, "mg/dL", 140, 760, 0, 130],
|
||||
[TOPICS_LAB_MARKER, "mg/dL", 121, 390, 0, 130],
|
||||
[TOPICS_LAB_MARKER, "mg/dL", 98, 20, 0, 130],
|
||||
["Vitamin D", "ng/mL", 24, 25, 30, 100],
|
||||
] as const) {
|
||||
await post("/api/labs", {
|
||||
analyte,
|
||||
panel: "Topics",
|
||||
value,
|
||||
unit,
|
||||
referenceLow: low,
|
||||
referenceHigh: high,
|
||||
takenAt: new Date(now - daysAgo * DAY_MS).toISOString(),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await pool.end();
|
||||
await ctx.dispose();
|
||||
}
|
||||
}
|
||||
@@ -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,219 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
import { TOPICS_STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { expectNoHorizontalOverflow } from "./utils/horizontal-overflow";
|
||||
|
||||
/**
|
||||
* The Topics preview end to end, on its own account (`E2E_TOPICS`, seeded by
|
||||
* `topics-fixture.ts`):
|
||||
*
|
||||
* - switched off (every account's start): no More tile, no sidebar entry,
|
||||
* the pages leave for Insights and the routes answer 404;
|
||||
* - switched on in Settings > System > Preview features: the More sheet
|
||||
* leads with a Topics tile carrying the Beta badge, the desktop sidebar
|
||||
* has the entry directly below Insights;
|
||||
* - every offered topic renders from the seeded record, with the relationship
|
||||
* that has fourteen paired days stated and the ones short of it saying how
|
||||
* far they are, the lab trend drawn for the marker with three readings over
|
||||
* a year and named as missing for the one with a single reading.
|
||||
*
|
||||
* Phone project only, in one serial run: the switch is account-wide, so two
|
||||
* projects toggling it at once would read each other's state.
|
||||
*/
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
async function setPreview(page: Page, topics: boolean) {
|
||||
const res = await page.request.patch("/api/settings/preview-features", {
|
||||
data: { topics },
|
||||
});
|
||||
expect(res.ok(), `preview PATCH answered ${res.status()}`).toBe(true);
|
||||
}
|
||||
|
||||
async function openMoreSheet(page: Page) {
|
||||
await page.locator('[data-testid="bottom-nav-more"]').click();
|
||||
const sheet = page.locator('[data-testid="bottom-nav-more-sheet"]');
|
||||
await expect(sheet).toBeVisible();
|
||||
return sheet;
|
||||
}
|
||||
|
||||
test.describe("Topics (preview)", () => {
|
||||
test.use({
|
||||
storageState: TOPICS_STORAGE_STATE_PATH,
|
||||
viewport: { width: 390, height: 844 },
|
||||
});
|
||||
|
||||
test.beforeEach(async ({}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== "chromium-mobile", "phone journey");
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }, testInfo) => {
|
||||
if (testInfo.project.name !== "chromium-mobile") return;
|
||||
const context = await browser.newContext({
|
||||
storageState: TOPICS_STORAGE_STATE_PATH,
|
||||
baseURL: testInfo.project.use.baseURL,
|
||||
});
|
||||
// Leave the account as every account starts: the preview off.
|
||||
await context.request.patch("/api/settings/preview-features", {
|
||||
data: { topics: false },
|
||||
});
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test("switched off: no tile, no entry, the pages leave and the routes refuse", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setPreview(page, false);
|
||||
await page.goto("/");
|
||||
const sheet = await openMoreSheet(page);
|
||||
await expect(sheet.locator('a[href="/topics"]')).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.goto("/topics");
|
||||
await page.waitForURL(/\/insights$/);
|
||||
await page.goto("/topics/heart");
|
||||
await page.waitForURL(/\/insights$/);
|
||||
|
||||
const api = await page.request.get("/api/topics");
|
||||
expect(api.status()).toBe(404);
|
||||
expect(
|
||||
((await api.json()) as { meta?: { errorCode?: string } }).meta,
|
||||
).toMatchObject({ errorCode: "topics.preview_off" });
|
||||
});
|
||||
|
||||
test("switched on in Settings: the More sheet leads with the Topics tile", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setPreview(page, false);
|
||||
await page.goto("/settings/preview");
|
||||
const toggle = page.locator('[data-slot="settings-preview-topics-switch"]');
|
||||
await expect(toggle).toHaveAttribute("aria-checked", "false");
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-checked", "true");
|
||||
await expect(
|
||||
page.locator('[data-slot="settings-preview-topics-open"]'),
|
||||
).toBeVisible();
|
||||
|
||||
const sheet = await openMoreSheet(page);
|
||||
const tiles = sheet.locator("a");
|
||||
await expect(tiles.first()).toHaveAttribute("href", "/topics");
|
||||
await expect(
|
||||
tiles.first().locator('[data-slot="preview-badge"]'),
|
||||
).toBeVisible();
|
||||
await tiles.first().click();
|
||||
await page.waitForURL(/\/topics$/);
|
||||
await expect(page.locator('[data-slot="topics-grid"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("every offered topic renders from the record, below-threshold states included", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setPreview(page, true);
|
||||
await page.goto("/topics");
|
||||
const grid = page.locator('[data-slot="topics-grid"]');
|
||||
await expect(grid).toBeVisible();
|
||||
for (const key of [
|
||||
"heart",
|
||||
"body",
|
||||
"blood-pressure",
|
||||
"sleep",
|
||||
"activity",
|
||||
"metabolism",
|
||||
"mind",
|
||||
]) {
|
||||
await expect(
|
||||
grid.locator(`[data-slot="topic-tile"][data-topic="${key}"]`),
|
||||
).toBeVisible();
|
||||
}
|
||||
// No cycle module for this account: named once, never a tile.
|
||||
await expect(
|
||||
grid.locator('[data-slot="topic-tile"][data-topic="cycle"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator('[data-slot="topics-hidden-list"]'),
|
||||
).toBeVisible();
|
||||
await expectNoHorizontalOverflow(page, "/topics");
|
||||
|
||||
// The heart: the pair with 40 paired days speaks, the steps pair cannot.
|
||||
await grid
|
||||
.locator('[data-topic="heart"] [data-slot="topic-tile-link"]')
|
||||
.click();
|
||||
await page.waitForURL(/\/topics\/heart$/);
|
||||
const body = page.locator('[data-slot="topic-detail-body"]');
|
||||
await expect(body).toBeVisible();
|
||||
await expect(
|
||||
body.locator(
|
||||
'[data-slot="topic-relationship"][data-relationship="hrv-resting-hr"]',
|
||||
),
|
||||
).toHaveAttribute("data-status", "linked");
|
||||
await expect(
|
||||
body.locator(
|
||||
'[data-slot="topic-signal"][data-signal="RESTING_HEART_RATE"]',
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
body.locator('[data-slot="topic-chart-row"]').first(),
|
||||
).toBeVisible();
|
||||
await expect(body.locator('[data-slot="topic-missing"]')).toBeVisible();
|
||||
await expectNoHorizontalOverflow(page, "/topics/heart");
|
||||
|
||||
// The range switch re-reads the window.
|
||||
await page
|
||||
.locator('[data-slot="topic-range"]')
|
||||
.getByRole("radio", { name: /90/ })
|
||||
.click();
|
||||
await expect(body).toBeVisible();
|
||||
|
||||
await page.goto("/topics/activity");
|
||||
await expect(
|
||||
page.locator(
|
||||
'[data-slot="topic-relationship"][data-relationship="steps-sleep"]',
|
||||
),
|
||||
).toHaveAttribute("data-status", "insufficient");
|
||||
|
||||
await page.goto("/topics/mind");
|
||||
await expect(
|
||||
page.locator(
|
||||
'[data-slot="topic-relationship"][data-relationship="short-nights-mood"]',
|
||||
),
|
||||
).toHaveAttribute("data-status", "insufficient");
|
||||
|
||||
await page.goto("/topics/metabolism");
|
||||
const labs = page.locator('[data-slot="topic-labs"]');
|
||||
await expect(labs).toBeVisible();
|
||||
await expect(labs.locator('[data-slot="topic-lab-trend"]')).toHaveCount(1);
|
||||
await expect(
|
||||
labs.locator('[data-slot="topic-lab-trend-short"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
labs.locator('[data-slot="topic-lab-marker"][data-status="below"]'),
|
||||
).toHaveCount(1);
|
||||
await expectNoHorizontalOverflow(page, "/topics/metabolism");
|
||||
|
||||
for (const key of ["body", "blood-pressure", "sleep"]) {
|
||||
await page.goto(`/topics/${key}`);
|
||||
await expect(
|
||||
page.locator('[data-slot="topic-detail-body"]'),
|
||||
).toBeVisible();
|
||||
await expectNoHorizontalOverflow(page, `/topics/${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the desktop sidebar carries the entry directly below Insights", async ({
|
||||
page,
|
||||
}) => {
|
||||
await setPreview(page, true);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.goto("/topics");
|
||||
const nav = page.locator('[data-slot="sidebar-column"] nav');
|
||||
const links = nav.locator("a");
|
||||
await expect(nav.locator('a[href="/topics"]')).toBeVisible();
|
||||
const hrefs = await links.evaluateAll((els) =>
|
||||
els.map((el) => el.getAttribute("href")),
|
||||
);
|
||||
expect(hrefs[hrefs.indexOf("/insights") + 1]).toBe("/topics");
|
||||
await expect(
|
||||
nav.locator('a[href="/topics"] [data-slot="preview-badge"]'),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
@@ -215,6 +236,7 @@ const eslintConfig = defineConfig([
|
||||
"src/components/charts/workout-elevation-chart.tsx",
|
||||
"src/components/insights/coach-panel/result-chart.tsx",
|
||||
"src/components/insights/derived/score-trend-chart.tsx",
|
||||
"src/components/topics/topic-side-by-side-chart.tsx",
|
||||
],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
|
||||
+263
-66
@@ -509,7 +509,9 @@
|
||||
"documents": "Dokumente",
|
||||
"vaccinations": "Impfungen",
|
||||
"mentalWellbeing": "Seelisches Wohlbefinden",
|
||||
"timeline": "Zeitleiste"
|
||||
"timeline": "Zeitleiste",
|
||||
"topics": "Themen",
|
||||
"previewBadge": "Beta"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Anmelden",
|
||||
@@ -3231,11 +3233,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 +3601,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.",
|
||||
@@ -6142,6 +6144,10 @@
|
||||
"access": {
|
||||
"title": "Geteilter Zugriff",
|
||||
"subtitle": "Wer deine Gesundheitsakte öffnen kann und wessen Akte du öffnen kannst."
|
||||
},
|
||||
"preview": {
|
||||
"title": "Vorschau-Funktionen",
|
||||
"subtitle": "Probier aus, was noch nicht fertig ist. Alles hier bleibt aus, bis du es einschaltest."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -6558,7 +6564,8 @@
|
||||
"medium": "Mittel",
|
||||
"high": "Hoch"
|
||||
},
|
||||
"coachNote": "Im Coach gilt die Denktiefe aus den Coach-Einstellungen."
|
||||
"coachNote": "Im Coach gilt die Denktiefe aus den Coach-Einstellungen.",
|
||||
"backgroundNote": "Hintergrundarbeit wie das Tagesbriefing nutzt diese Einstellung nicht: Sie denkt höchstens auf Mittel nach, weniger, wenn der Betreiber eine niedrigere Grenze setzt, und gar nicht auf der Stufe Sparsam."
|
||||
},
|
||||
"providerCardDescription": "Richte Schlüssel und Modell je Anbieter ein; die Reihenfolge darunter bestimmt, welchen HealthLog zuerst fragt.",
|
||||
"runtimeTitle": "Prüfen und aktualisieren",
|
||||
@@ -7430,6 +7437,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}"
|
||||
@@ -7625,6 +7633,17 @@
|
||||
"passkeyRemovedToast": "Passkey entfernt.",
|
||||
"integrationDisconnected": "{provider} getrennt.",
|
||||
"integrationDisconnectFailed": "{provider} konnte nicht getrennt werden.",
|
||||
"preview": {
|
||||
"topics": {
|
||||
"title": "Themen",
|
||||
"description": "Fügt einen Bereich Themen hinzu, der zusammengehörige Werte bündelt, neben deinen Insights-Seiten.",
|
||||
"detail": "Acht Themen, von Herz bis Zyklus, zeigen deine Werte nebeneinander, wie sie zusammenhängen, und führen in die Einzelseiten, die du schon kennst. Deine Insights-Seiten, die Navigation und alle Links bleiben genau, wie sie sind. Alles wird aus deinen eigenen Werten berechnet, ohne KI. Ausschalten entfernt den Eintrag und löscht nichts.",
|
||||
"savedOn": "Themen-Vorschau eingeschaltet.",
|
||||
"savedOff": "Themen-Vorschau ausgeschaltet.",
|
||||
"open": "Themen öffnen"
|
||||
},
|
||||
"loadError": "Deine Vorschau-Einstellungen konnten nicht geladen werden."
|
||||
},
|
||||
"timezoneSaved": "Zeitzone gespeichert.",
|
||||
"moduleState": {
|
||||
"on": "An",
|
||||
@@ -10426,6 +10445,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)",
|
||||
@@ -10949,11 +10970,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.",
|
||||
@@ -10961,7 +10988,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": {
|
||||
@@ -11010,8 +11040,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": {
|
||||
@@ -11019,9 +11049,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",
|
||||
@@ -11029,16 +11059,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": {
|
||||
@@ -11068,6 +11088,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": {
|
||||
@@ -11076,7 +11108,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",
|
||||
@@ -11097,51 +11128,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",
|
||||
@@ -11156,6 +11151,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": {
|
||||
@@ -11893,7 +11925,6 @@
|
||||
},
|
||||
"chat": {
|
||||
"title": "Über dieses Dokument chatten",
|
||||
"openAria": "Coach über dieses Dokument fragen",
|
||||
"openButton": "Coach fragen"
|
||||
},
|
||||
"readingConsent": {
|
||||
@@ -13811,5 +13842,171 @@
|
||||
"startsOn": "Beginn",
|
||||
"endsOn": "Ende"
|
||||
}
|
||||
},
|
||||
"topics": {
|
||||
"overview": {
|
||||
"title": "Themen",
|
||||
"description": "Zusammengehörige Werte, aus deinen eigenen Daten gebündelt; öffne ein Thema, um sie nebeneinander zu sehen.",
|
||||
"emptyTitle": "Noch nichts zum Bündeln",
|
||||
"emptyDescription": "Themen erscheinen, sobald es Werte dafür gibt: von einem Gerät, aus einem Import oder von dir eingetragen.",
|
||||
"notRecorded": "Noch keine Werte für: {topics}.",
|
||||
"hiddenByModule": "Ausgeblendet, weil das Modul aus ist: {topics}.",
|
||||
"modulesLink": "Module in den Einstellungen ändern"
|
||||
},
|
||||
"detail": {
|
||||
"back": "Alle Themen"
|
||||
},
|
||||
"loadError": "Die Themen konnten nicht geladen werden.",
|
||||
"previewNote": "Das ist eine Vorschau. Sie kann sich zwischen Versionen ändern, und deine Einzelseiten bleiben, wie sie sind.",
|
||||
"recorded": "Erfasste Werte: {count} von {total}",
|
||||
"tone": {
|
||||
"steady": "Stabil",
|
||||
"watch": "Einen Blick wert",
|
||||
"learning": "Lernt noch"
|
||||
},
|
||||
"summary": {
|
||||
"outsideAbove": "{signal} liegt über deinem üblichen Bereich.",
|
||||
"outsideBelow": "{signal} liegt unter deinem üblichen Bereich.",
|
||||
"labs": "{count} von {total} Laborwerten liegen im Referenzbereich.",
|
||||
"cycleDay": "Zyklustag {day}.",
|
||||
"steadyAll": "Alles Erfasste liegt in deinem üblichen Bereich.",
|
||||
"steadySome": "{count} von {total} Werten liegen in deinem üblichen Bereich; für die übrigen lernt HealthLog noch.",
|
||||
"learning": "Dein üblicher Bereich wird noch gelernt. Eine Woche mit Werten reicht.",
|
||||
"empty": "Noch nichts erfasst."
|
||||
},
|
||||
"headline": {
|
||||
"labsValue": "{count}/{total}",
|
||||
"labsUnit": "im Bereich",
|
||||
"cycleDay": "Tag {day}"
|
||||
},
|
||||
"signals": {
|
||||
"mood": "Stimmung"
|
||||
},
|
||||
"units": {
|
||||
"moodScale": "von 5"
|
||||
},
|
||||
"names": {
|
||||
"heart": {
|
||||
"title": "Herz",
|
||||
"description": "Ruhepuls, Herzfrequenzvariabilität und Fitness, mit dem Schlaf und Training, die sie bewegen."
|
||||
},
|
||||
"body": {
|
||||
"title": "Körper",
|
||||
"description": "Gewicht und Körperzusammensetzung im Verlauf."
|
||||
},
|
||||
"bloodPressure": {
|
||||
"title": "Blutdruck",
|
||||
"description": "Systolisch, diastolisch und Puls, mit Schlaf und Aktivität drumherum."
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Schlaf",
|
||||
"description": "Schlafdauer und die Werte, die nachts gemessen werden."
|
||||
},
|
||||
"activity": {
|
||||
"title": "Aktivität & Training",
|
||||
"description": "Schritte, Trainingsminuten, aktive Energie und Trainingslast."
|
||||
},
|
||||
"metabolism": {
|
||||
"title": "Stoffwechsel & Labor",
|
||||
"description": "Blutzucker und deine Laborwerte, mit dem Trend jedes Markers."
|
||||
},
|
||||
"mind": {
|
||||
"title": "Psyche",
|
||||
"description": "Stimmung und Fragebögen neben Schlaf und Aktivität."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Zyklus",
|
||||
"description": "Dein Zyklus mit der Temperatur und dem Ruhepuls, die ihm folgen."
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
"heart": "Diese Werte kommen von einer Uhr, einem Ring oder Brustgurt, aus Apple Health oder Health Connect oder von dir eingetragen.",
|
||||
"body": "Diese Werte kommen von einer Waage, aus einem Import oder von dir eingetragen.",
|
||||
"bloodPressure": "Diese Werte kommen von einem Blutdruckmessgerät, aus einem Import oder von dir eingetragen.",
|
||||
"sleep": "Diese Werte kommen von einer Uhr oder einem Ring, aus Apple Health oder Health Connect oder von dir eingetragen.",
|
||||
"activity": "Diese Werte kommen von Handy, Uhr oder Ring oder aus Apple Health oder Health Connect.",
|
||||
"metabolism": "Blutzucker kommt von einem Messgerät, aus einem Sensor-Import oder von dir; Laborwerte aus einem Laborbericht oder von dir eingetragen.",
|
||||
"mind": "Diese Werte kommen aus deinen Stimmungseinträgen und den Fragebögen auf der Seite Seelisches Wohlbefinden.",
|
||||
"cycle": "Diese Werte kommen aus deinem Zyklusprotokoll und einem Temperatursensor am Handgelenk."
|
||||
},
|
||||
"today": {
|
||||
"title": "Heute",
|
||||
"usual": "Üblich {low}–{high}",
|
||||
"learning": "Üblicher Bereich wird noch gelernt",
|
||||
"asOf": "Stand {date}",
|
||||
"details": "Details"
|
||||
},
|
||||
"state": {
|
||||
"within": "Im Bereich",
|
||||
"above": "Über üblich",
|
||||
"below": "Unter üblich",
|
||||
"learning": "Lernt noch"
|
||||
},
|
||||
"chart": {
|
||||
"title": "Nebeneinander",
|
||||
"legend": "Schattiert: dein üblicher Bereich.",
|
||||
"label": "{signals} im gewählten Zeitraum, je eine Zeile."
|
||||
},
|
||||
"range": {
|
||||
"label": "Zeitraum",
|
||||
"7d": "7 T",
|
||||
"30d": "30 T",
|
||||
"90d": "90 T",
|
||||
"1y": "1 J"
|
||||
},
|
||||
"relationships": {
|
||||
"title": "Wie sie zusammenhängen",
|
||||
"insufficient": "{x} und {y}: bisher {count} von {required} gemeinsamen Tagen.",
|
||||
"noClearLink": "Kein klarer Zusammenhang zwischen {x} und {y} über {count} gemeinsame Tage.",
|
||||
"splitHigher": "Wenn {x} unter {threshold} lag, war {y} um {amount} höher als an anderen Tagen.",
|
||||
"splitLower": "Wenn {x} unter {threshold} lag, war {y} um {amount} niedriger als an anderen Tagen.",
|
||||
"splitDays": "Tage darunter: {below}. Andere Tage: {other}.",
|
||||
"correlationSameDayHigher": "Wenn {x} höher liegt, ist auch {y} meist höher (r = {r}).",
|
||||
"correlationSameDayLower": "Wenn {x} höher liegt, ist {y} meist niedriger (r = {r}).",
|
||||
"correlationNextDayHigher": "Wenn {x} höher liegt, ist {y} am nächsten Tag meist höher (r = {r}).",
|
||||
"correlationNextDayLower": "Wenn {x} höher liegt, ist {y} am nächsten Tag meist niedriger (r = {r}).",
|
||||
"pairedDays": "Gemeinsame Tage: {count}",
|
||||
"footnote": "Erscheint, sobald zwei Werte an mindestens 14 Tagen zusammen vorliegen. Ein Muster, keine Ursache.",
|
||||
"scatterCaption": "Jeder Punkt ist ein Tag: {x} nach rechts, {y} nach oben."
|
||||
},
|
||||
"trends": {
|
||||
"title": "Trends",
|
||||
"window": "90 Tage, Wochenmittel",
|
||||
"insufficient": "Braucht 14 Tage, bisher {count}"
|
||||
},
|
||||
"labs": {
|
||||
"title": "Laborwerte",
|
||||
"above": "Über dem Bereich",
|
||||
"below": "Unter dem Bereich",
|
||||
"trendsTitle": "Trends",
|
||||
"trendNeeds": "Ein Trend braucht drei Werte über mindestens ein Jahr. Noch nicht so weit: {markers}."
|
||||
},
|
||||
"screeners": {
|
||||
"title": "Fragebögen",
|
||||
"none": "Noch keine Fragebögen ausgefüllt.",
|
||||
"checkIns": "Ausgefüllt: {count}",
|
||||
"improved": "Besser als beim ersten Mal",
|
||||
"worsened": "Schlechter als beim ersten Mal",
|
||||
"within_threshold": "Im üblichen Schwankungsbereich",
|
||||
"single": "Bisher einmal ausgefüllt",
|
||||
"open": "Seelisches Wohlbefinden öffnen"
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Zyklus",
|
||||
"today": "Zyklustag {day}.",
|
||||
"todayWithPhase": "Zyklustag {day}, {phase}.",
|
||||
"average": "Durchschnittliche Zykluslänge: {days} Tage.",
|
||||
"needs": "Ein Durchschnitt braucht zwei erfasste Periodenbeginne; bisher sind es {count}.",
|
||||
"lengths": "Letzte Zyklen in Tagen: {lengths}",
|
||||
"open": "Zyklus öffnen"
|
||||
},
|
||||
"links": {
|
||||
"title": "Einzelseiten",
|
||||
"workouts": "Workouts"
|
||||
},
|
||||
"missing": {
|
||||
"title": "Noch nicht erfasst: {signals}.",
|
||||
"connect": "Quelle verbinden"
|
||||
}
|
||||
}
|
||||
}
|
||||
+257
-8
@@ -509,7 +509,9 @@
|
||||
"documents": "Documents",
|
||||
"vaccinations": "Vaccinations",
|
||||
"mentalWellbeing": "Mental wellbeing",
|
||||
"timeline": "Timeline"
|
||||
"timeline": "Timeline",
|
||||
"topics": "Topics",
|
||||
"previewBadge": "Beta"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Sign in",
|
||||
@@ -3231,11 +3233,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",
|
||||
@@ -6142,6 +6144,10 @@
|
||||
"access": {
|
||||
"title": "Shared access",
|
||||
"subtitle": "Who can open your health record, and whose you can open."
|
||||
},
|
||||
"preview": {
|
||||
"title": "Preview features",
|
||||
"subtitle": "Try what is not finished yet. Everything here stays off until you switch it on."
|
||||
}
|
||||
},
|
||||
"profile": "Profile",
|
||||
@@ -6558,7 +6564,8 @@
|
||||
"medium": "Medium",
|
||||
"high": "High"
|
||||
},
|
||||
"coachNote": "In the Coach, the thinking depth from the Coach settings applies."
|
||||
"coachNote": "In the Coach, the thinking depth from the Coach settings applies.",
|
||||
"backgroundNote": "Background work such as the daily briefing does not use this setting: it thinks at Medium at most, lower when the operator sets a lower limit, and not at all on the Frugal usage level."
|
||||
},
|
||||
"providerCardDescription": "Set up each provider's key and model; the order below decides which one HealthLog asks first.",
|
||||
"runtimeTitle": "Check and refresh",
|
||||
@@ -7430,6 +7437,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}"
|
||||
@@ -7625,6 +7633,17 @@
|
||||
"passkeyRemovedToast": "Passkey removed.",
|
||||
"integrationDisconnected": "{provider} disconnected.",
|
||||
"integrationDisconnectFailed": "Could not disconnect {provider}.",
|
||||
"preview": {
|
||||
"topics": {
|
||||
"title": "Topics",
|
||||
"description": "Adds a Topics area that brings related signals together, beside your Insights pages.",
|
||||
"detail": "Eight topics, from heart to cycle, show your signals side by side, how they move together and links into the single-metric pages you already use. Your Insights pages, navigation and links stay exactly as they are. Everything is computed from your own values, without AI. Switching it off removes the entry and deletes nothing.",
|
||||
"savedOn": "Topics preview switched on.",
|
||||
"savedOff": "Topics preview switched off.",
|
||||
"open": "Open Topics"
|
||||
},
|
||||
"loadError": "Could not load your preview settings."
|
||||
},
|
||||
"timezoneSaved": "Timezone saved.",
|
||||
"moduleState": {
|
||||
"on": "On",
|
||||
@@ -10426,6 +10445,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)",
|
||||
@@ -10949,7 +10970,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",
|
||||
@@ -10961,7 +10988,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": {
|
||||
@@ -11068,6 +11098,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": {
|
||||
@@ -11076,7 +11118,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",
|
||||
@@ -11135,6 +11176,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 …"
|
||||
},
|
||||
@@ -11156,6 +11203,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": {
|
||||
@@ -11893,7 +11977,6 @@
|
||||
},
|
||||
"chat": {
|
||||
"title": "Chat about this document",
|
||||
"openAria": "Ask the Coach about this document",
|
||||
"openButton": "Ask the Coach"
|
||||
},
|
||||
"readingConsent": {
|
||||
@@ -13811,5 +13894,171 @@
|
||||
"startsOn": "Start",
|
||||
"endsOn": "End"
|
||||
}
|
||||
},
|
||||
"topics": {
|
||||
"overview": {
|
||||
"title": "Topics",
|
||||
"description": "Related signals brought together from your own values; open one to see them side by side.",
|
||||
"emptyTitle": "Nothing to bring together yet",
|
||||
"emptyDescription": "Topics appear once there are readings for them, from a device, an import or your own entries.",
|
||||
"notRecorded": "Nothing recorded yet for: {topics}.",
|
||||
"hiddenByModule": "Hidden because the module is off: {topics}.",
|
||||
"modulesLink": "Change modules in Settings"
|
||||
},
|
||||
"detail": {
|
||||
"back": "All topics"
|
||||
},
|
||||
"loadError": "Could not load the topics.",
|
||||
"previewNote": "This is a preview. It may change between releases, and your single-metric pages stay as they are.",
|
||||
"recorded": "Signals recorded: {count} of {total}",
|
||||
"tone": {
|
||||
"steady": "Steady",
|
||||
"watch": "Worth a look",
|
||||
"learning": "Learning"
|
||||
},
|
||||
"summary": {
|
||||
"outsideAbove": "{signal} is above your usual range.",
|
||||
"outsideBelow": "{signal} is below your usual range.",
|
||||
"labs": "{count} of {total} lab values sit inside their reference range.",
|
||||
"cycleDay": "Cycle day {day}.",
|
||||
"steadyAll": "Everything recorded sits inside your usual range.",
|
||||
"steadySome": "{count} of {total} signals sit inside your usual range; the others are still being learned.",
|
||||
"learning": "Still learning your usual range. A week of readings is enough.",
|
||||
"empty": "Nothing recorded yet."
|
||||
},
|
||||
"headline": {
|
||||
"labsValue": "{count}/{total}",
|
||||
"labsUnit": "in range",
|
||||
"cycleDay": "Day {day}"
|
||||
},
|
||||
"signals": {
|
||||
"mood": "Mood"
|
||||
},
|
||||
"units": {
|
||||
"moodScale": "of 5"
|
||||
},
|
||||
"names": {
|
||||
"heart": {
|
||||
"title": "Heart",
|
||||
"description": "Resting heart rate, heart-rate variability and cardio fitness, with the sleep and training that move them."
|
||||
},
|
||||
"body": {
|
||||
"title": "Body",
|
||||
"description": "Weight and body composition over time."
|
||||
},
|
||||
"bloodPressure": {
|
||||
"title": "Blood pressure",
|
||||
"description": "Systolic, diastolic and pulse, with the sleep and activity around them."
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sleep",
|
||||
"description": "Time asleep and the signals measured at night."
|
||||
},
|
||||
"activity": {
|
||||
"title": "Activity & training",
|
||||
"description": "Steps, exercise minutes, active energy and training load."
|
||||
},
|
||||
"metabolism": {
|
||||
"title": "Metabolism & labs",
|
||||
"description": "Blood glucose and your lab values, with each marker's trend."
|
||||
},
|
||||
"mind": {
|
||||
"title": "Mind",
|
||||
"description": "Mood and screener check-ins beside sleep and activity."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Cycle",
|
||||
"description": "Your cycle with the temperature and resting heart rate that follow it."
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
"heart": "These come from a watch, ring or chest strap, from Apple Health or Health Connect, or from your own entries.",
|
||||
"body": "These come from a smart scale, an import or your own entries.",
|
||||
"bloodPressure": "These come from a blood pressure monitor, an import or your own entries.",
|
||||
"sleep": "These come from a watch or ring, from Apple Health or Health Connect, or from your own entries.",
|
||||
"activity": "These come from a phone, watch or ring, or from Apple Health or Health Connect.",
|
||||
"metabolism": "Glucose comes from a meter, a sensor import or your own entries; lab values from a lab report or your own entries.",
|
||||
"mind": "These come from mood entries and the check-ins on the mental wellbeing page.",
|
||||
"cycle": "These come from your cycle log and a wrist temperature sensor."
|
||||
},
|
||||
"today": {
|
||||
"title": "Today",
|
||||
"usual": "Usual {low}–{high}",
|
||||
"learning": "Usual range still being learned",
|
||||
"asOf": "As of {date}",
|
||||
"details": "Details"
|
||||
},
|
||||
"state": {
|
||||
"within": "Within",
|
||||
"above": "Above usual",
|
||||
"below": "Below usual",
|
||||
"learning": "Learning"
|
||||
},
|
||||
"chart": {
|
||||
"title": "Side by side",
|
||||
"legend": "Shaded: your usual range.",
|
||||
"label": "{signals} over the selected period, one row each."
|
||||
},
|
||||
"range": {
|
||||
"label": "Period",
|
||||
"7d": "7d",
|
||||
"30d": "30d",
|
||||
"90d": "90d",
|
||||
"1y": "1y"
|
||||
},
|
||||
"relationships": {
|
||||
"title": "How they move together",
|
||||
"insufficient": "{x} and {y}: {count} of {required} paired days so far.",
|
||||
"noClearLink": "No clear link between {x} and {y} over {count} paired days.",
|
||||
"splitHigher": "When {x} was under {threshold}, {y} was {amount} higher than on other days.",
|
||||
"splitLower": "When {x} was under {threshold}, {y} was {amount} lower than on other days.",
|
||||
"splitDays": "Days under: {below}. Other days: {other}.",
|
||||
"correlationSameDayHigher": "When {x} runs higher, {y} tends to be higher too (r = {r}).",
|
||||
"correlationSameDayLower": "When {x} runs higher, {y} tends to be lower (r = {r}).",
|
||||
"correlationNextDayHigher": "When {x} runs higher, {y} the next day tends to be higher (r = {r}).",
|
||||
"correlationNextDayLower": "When {x} runs higher, {y} the next day tends to be lower (r = {r}).",
|
||||
"pairedDays": "Paired days: {count}",
|
||||
"footnote": "Shown once two signals overlap on at least 14 days. A pattern, not a cause.",
|
||||
"scatterCaption": "Each dot is one day: {x} across, {y} up."
|
||||
},
|
||||
"trends": {
|
||||
"title": "Trends",
|
||||
"window": "90 days, weekly means",
|
||||
"insufficient": "Needs 14 days, {count} so far"
|
||||
},
|
||||
"labs": {
|
||||
"title": "Lab values",
|
||||
"above": "Above range",
|
||||
"below": "Below range",
|
||||
"trendsTitle": "Trends",
|
||||
"trendNeeds": "A trend needs three values spanning a year. Not there yet: {markers}."
|
||||
},
|
||||
"screeners": {
|
||||
"title": "Check-ins",
|
||||
"none": "No screener check-ins yet.",
|
||||
"checkIns": "Check-ins: {count}",
|
||||
"improved": "Better than the first",
|
||||
"worsened": "Worse than the first",
|
||||
"within_threshold": "Within the usual wobble",
|
||||
"single": "One check-in so far",
|
||||
"open": "Open mental wellbeing"
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Cycle",
|
||||
"today": "Cycle day {day}.",
|
||||
"todayWithPhase": "Cycle day {day}, {phase} phase.",
|
||||
"average": "Average cycle length: {days} days.",
|
||||
"needs": "An average needs two logged period starts; there are {count} so far.",
|
||||
"lengths": "Recent cycles, in days: {lengths}",
|
||||
"open": "Open the cycle page"
|
||||
},
|
||||
"links": {
|
||||
"title": "Single-metric pages",
|
||||
"workouts": "Workouts"
|
||||
},
|
||||
"missing": {
|
||||
"title": "Not recorded yet: {signals}.",
|
||||
"connect": "Connect a source"
|
||||
}
|
||||
}
|
||||
}
|
||||
+281
-84
@@ -509,7 +509,9 @@
|
||||
"documents": "Documentos",
|
||||
"vaccinations": "Vacunas",
|
||||
"mentalWellbeing": "Bienestar mental",
|
||||
"timeline": "Cronología"
|
||||
"timeline": "Cronología",
|
||||
"topics": "Temas",
|
||||
"previewBadge": "Beta"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Iniciar sesión",
|
||||
@@ -1556,12 +1558,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 +3233,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 +3601,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.",
|
||||
@@ -6142,6 +6144,10 @@
|
||||
"access": {
|
||||
"title": "Acceso compartido",
|
||||
"subtitle": "Quién puede abrir tu historial de salud y cuál puedes abrir tú."
|
||||
},
|
||||
"preview": {
|
||||
"title": "Funciones en vista previa",
|
||||
"subtitle": "Prueba lo que aún no está terminado. Todo aquí sigue desactivado hasta que lo actives."
|
||||
}
|
||||
},
|
||||
"profile": "Perfil",
|
||||
@@ -6558,7 +6564,8 @@
|
||||
"medium": "Medio",
|
||||
"high": "Alto"
|
||||
},
|
||||
"coachNote": "En el Coach se aplica la profundidad de pensamiento de los ajustes del Coach."
|
||||
"coachNote": "En el Coach se aplica la profundidad de pensamiento de los ajustes del Coach.",
|
||||
"backgroundNote": "El trabajo en segundo plano, como el resumen diario, no usa este ajuste: piensa como máximo en Medio, menos si el operador fija un límite más bajo, y nada en el nivel Ahorro."
|
||||
},
|
||||
"providerCardDescription": "Configura la clave y el modelo de cada proveedor; el orden de abajo decide a cuál pregunta HealthLog primero.",
|
||||
"runtimeTitle": "Comprobar y actualizar",
|
||||
@@ -7430,6 +7437,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}"
|
||||
@@ -7625,6 +7633,17 @@
|
||||
"passkeyRemovedToast": "Passkey eliminada.",
|
||||
"integrationDisconnected": "{provider} desconectado.",
|
||||
"integrationDisconnectFailed": "No se pudo desconectar {provider}.",
|
||||
"preview": {
|
||||
"topics": {
|
||||
"title": "Temas",
|
||||
"description": "Añade un área Temas que reúne valores relacionados, junto a tus páginas de Insights.",
|
||||
"detail": "Ocho temas, del corazón al ciclo, muestran tus valores uno al lado del otro, cómo se mueven juntos y enlaces a las páginas por métrica que ya usas. Tus páginas de Insights, la navegación y los enlaces quedan exactamente como están. Todo se calcula con tus propios valores, sin IA. Desactivarlo quita la entrada y no borra nada.",
|
||||
"savedOn": "Vista previa de Temas activada.",
|
||||
"savedOff": "Vista previa de Temas desactivada.",
|
||||
"open": "Abrir Temas"
|
||||
},
|
||||
"loadError": "No se pudieron cargar tus ajustes de vista previa."
|
||||
},
|
||||
"timezoneSaved": "Zona horaria guardada.",
|
||||
"moduleState": {
|
||||
"on": "Activado",
|
||||
@@ -10426,6 +10445,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)",
|
||||
@@ -10947,21 +10968,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": {
|
||||
@@ -11004,24 +11034,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",
|
||||
@@ -11029,16 +11059,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": {
|
||||
@@ -11068,6 +11088,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": {
|
||||
@@ -11076,7 +11108,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",
|
||||
@@ -11097,51 +11128,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",
|
||||
@@ -11156,6 +11151,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": {
|
||||
@@ -11893,7 +11925,6 @@
|
||||
},
|
||||
"chat": {
|
||||
"title": "Chatea sobre este documento",
|
||||
"openAria": "Pregunta al Coach sobre este documento",
|
||||
"openButton": "Preguntar al Coach"
|
||||
},
|
||||
"readingConsent": {
|
||||
@@ -13811,5 +13842,171 @@
|
||||
"startsOn": "Inicio",
|
||||
"endsOn": "Fin"
|
||||
}
|
||||
},
|
||||
"topics": {
|
||||
"overview": {
|
||||
"title": "Temas",
|
||||
"description": "Valores relacionados, reunidos a partir de tus propios datos; abre un tema para verlos uno al lado del otro.",
|
||||
"emptyTitle": "Todavía no hay nada que reunir",
|
||||
"emptyDescription": "Los temas aparecen en cuanto hay valores para ellos: de un dispositivo, una importación o tus propios registros.",
|
||||
"notRecorded": "Aún sin registros para: {topics}.",
|
||||
"hiddenByModule": "Ocultos porque el módulo está desactivado: {topics}.",
|
||||
"modulesLink": "Cambiar módulos en los ajustes"
|
||||
},
|
||||
"detail": {
|
||||
"back": "Todos los temas"
|
||||
},
|
||||
"loadError": "No se pudieron cargar los temas.",
|
||||
"previewNote": "Esto es una vista previa. Puede cambiar entre versiones, y tus páginas por métrica siguen como están.",
|
||||
"recorded": "Valores registrados: {count} de {total}",
|
||||
"tone": {
|
||||
"steady": "Estable",
|
||||
"watch": "Merece un vistazo",
|
||||
"learning": "Aprendiendo"
|
||||
},
|
||||
"summary": {
|
||||
"outsideAbove": "{signal} está por encima de tu rango habitual.",
|
||||
"outsideBelow": "{signal} está por debajo de tu rango habitual.",
|
||||
"labs": "{count} de {total} análisis están dentro de su rango de referencia.",
|
||||
"cycleDay": "Día {day} del ciclo.",
|
||||
"steadyAll": "Todo lo registrado está dentro de tu rango habitual.",
|
||||
"steadySome": "{count} de {total} valores están dentro de tu rango habitual; los demás aún se están aprendiendo.",
|
||||
"learning": "Tu rango habitual aún se está aprendiendo. Basta con una semana de registros.",
|
||||
"empty": "Aún no hay nada registrado."
|
||||
},
|
||||
"headline": {
|
||||
"labsValue": "{count}/{total}",
|
||||
"labsUnit": "en rango",
|
||||
"cycleDay": "Día {day}"
|
||||
},
|
||||
"signals": {
|
||||
"mood": "Estado de ánimo"
|
||||
},
|
||||
"units": {
|
||||
"moodScale": "de 5"
|
||||
},
|
||||
"names": {
|
||||
"heart": {
|
||||
"title": "Corazón",
|
||||
"description": "Frecuencia cardíaca en reposo, variabilidad y forma cardiovascular, con el sueño y el entrenamiento que las mueven."
|
||||
},
|
||||
"body": {
|
||||
"title": "Cuerpo",
|
||||
"description": "Peso y composición corporal a lo largo del tiempo."
|
||||
},
|
||||
"bloodPressure": {
|
||||
"title": "Presión arterial",
|
||||
"description": "Sistólica, diastólica y pulso, con el sueño y la actividad a su alrededor."
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sueño",
|
||||
"description": "Tiempo dormido y los valores medidos por la noche."
|
||||
},
|
||||
"activity": {
|
||||
"title": "Actividad y entrenamiento",
|
||||
"description": "Pasos, minutos de ejercicio, energía activa y carga de entrenamiento."
|
||||
},
|
||||
"metabolism": {
|
||||
"title": "Metabolismo y análisis",
|
||||
"description": "Glucosa y tus análisis, con la tendencia de cada marcador."
|
||||
},
|
||||
"mind": {
|
||||
"title": "Mente",
|
||||
"description": "Estado de ánimo y cuestionarios junto al sueño y la actividad."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Ciclo",
|
||||
"description": "Tu ciclo con la temperatura y la frecuencia cardíaca en reposo que lo acompañan."
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
"heart": "Estos valores vienen de un reloj, un anillo o una banda pectoral, de Apple Salud o Health Connect, o de tus propios registros.",
|
||||
"body": "Estos valores vienen de una báscula inteligente, una importación o tus propios registros.",
|
||||
"bloodPressure": "Estos valores vienen de un tensiómetro, una importación o tus propios registros.",
|
||||
"sleep": "Estos valores vienen de un reloj o un anillo, de Apple Salud o Health Connect, o de tus propios registros.",
|
||||
"activity": "Estos valores vienen de un teléfono, un reloj o un anillo, o de Apple Salud o Health Connect.",
|
||||
"metabolism": "La glucosa viene de un medidor, una importación de sensor o tus registros; los análisis, de un informe de laboratorio o de tus registros.",
|
||||
"mind": "Estos valores vienen de tus registros de ánimo y de los cuestionarios de la página Bienestar mental.",
|
||||
"cycle": "Estos valores vienen de tu registro del ciclo y de un sensor de temperatura en la muñeca."
|
||||
},
|
||||
"today": {
|
||||
"title": "Hoy",
|
||||
"usual": "Habitual {low}–{high}",
|
||||
"learning": "Rango habitual aún en aprendizaje",
|
||||
"asOf": "A fecha de {date}",
|
||||
"details": "Detalles"
|
||||
},
|
||||
"state": {
|
||||
"within": "Dentro",
|
||||
"above": "Por encima",
|
||||
"below": "Por debajo",
|
||||
"learning": "Aprendiendo"
|
||||
},
|
||||
"chart": {
|
||||
"title": "Uno al lado del otro",
|
||||
"legend": "Sombreado: tu rango habitual.",
|
||||
"label": "{signals} en el periodo elegido, una fila cada uno."
|
||||
},
|
||||
"range": {
|
||||
"label": "Periodo",
|
||||
"7d": "7 d",
|
||||
"30d": "30 d",
|
||||
"90d": "90 d",
|
||||
"1y": "1 a"
|
||||
},
|
||||
"relationships": {
|
||||
"title": "Cómo se mueven juntos",
|
||||
"insufficient": "{x} y {y}: {count} de {required} días emparejados por ahora.",
|
||||
"noClearLink": "Sin relación clara entre {x} y {y} en {count} días emparejados.",
|
||||
"splitHigher": "Cuando {x} estuvo por debajo de {threshold}, {y} fue {amount} más alto que otros días.",
|
||||
"splitLower": "Cuando {x} estuvo por debajo de {threshold}, {y} fue {amount} más bajo que otros días.",
|
||||
"splitDays": "Días por debajo: {below}. Otros días: {other}.",
|
||||
"correlationSameDayHigher": "Cuando {x} sube, {y} también suele estar más alto (r = {r}).",
|
||||
"correlationSameDayLower": "Cuando {x} sube, {y} suele estar más bajo (r = {r}).",
|
||||
"correlationNextDayHigher": "Cuando {x} sube, {y} suele estar más alto al día siguiente (r = {r}).",
|
||||
"correlationNextDayLower": "Cuando {x} sube, {y} suele estar más bajo al día siguiente (r = {r}).",
|
||||
"pairedDays": "Días emparejados: {count}",
|
||||
"footnote": "Se muestra cuando dos valores coinciden en al menos 14 días. Un patrón, no una causa.",
|
||||
"scatterCaption": "Cada punto es un día: {x} en horizontal, {y} en vertical."
|
||||
},
|
||||
"trends": {
|
||||
"title": "Tendencias",
|
||||
"window": "90 días, medias semanales",
|
||||
"insufficient": "Necesita 14 días, {count} por ahora"
|
||||
},
|
||||
"labs": {
|
||||
"title": "Análisis",
|
||||
"above": "Por encima del rango",
|
||||
"below": "Por debajo del rango",
|
||||
"trendsTitle": "Tendencias",
|
||||
"trendNeeds": "Una tendencia necesita tres valores a lo largo de al menos un año. Aún no: {markers}."
|
||||
},
|
||||
"screeners": {
|
||||
"title": "Cuestionarios",
|
||||
"none": "Aún no has completado ningún cuestionario.",
|
||||
"checkIns": "Completados: {count}",
|
||||
"improved": "Mejor que la primera vez",
|
||||
"worsened": "Peor que la primera vez",
|
||||
"within_threshold": "Dentro de la variación habitual",
|
||||
"single": "Completado una vez por ahora",
|
||||
"open": "Abrir Bienestar mental"
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Ciclo",
|
||||
"today": "Día {day} del ciclo.",
|
||||
"todayWithPhase": "Día {day} del ciclo, fase {phase}.",
|
||||
"average": "Duración media del ciclo: {days} días.",
|
||||
"needs": "Una media necesita dos inicios de periodo registrados; por ahora hay {count}.",
|
||||
"lengths": "Últimos ciclos, en días: {lengths}",
|
||||
"open": "Abrir la página del ciclo"
|
||||
},
|
||||
"links": {
|
||||
"title": "Páginas por métrica",
|
||||
"workouts": "Entrenamientos"
|
||||
},
|
||||
"missing": {
|
||||
"title": "Aún sin registrar: {signals}.",
|
||||
"connect": "Conectar una fuente"
|
||||
}
|
||||
}
|
||||
}
|
||||
+259
-89
@@ -509,7 +509,9 @@
|
||||
"documents": "Documents",
|
||||
"vaccinations": "Vaccinations",
|
||||
"mentalWellbeing": "Bien-être mental",
|
||||
"timeline": "Chronologie"
|
||||
"timeline": "Chronologie",
|
||||
"topics": "Thèmes",
|
||||
"previewBadge": "Bêta"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Se connecter",
|
||||
@@ -1556,11 +1558,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 +3233,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 +3601,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.",
|
||||
@@ -6142,6 +6144,10 @@
|
||||
"access": {
|
||||
"title": "Accès partagé",
|
||||
"subtitle": "Qui peut ouvrir votre dossier de santé, et lequel vous pouvez ouvrir."
|
||||
},
|
||||
"preview": {
|
||||
"title": "Fonctions en avant-première",
|
||||
"subtitle": "Essayez ce qui n'est pas encore terminé. Tout reste désactivé ici jusqu'à ce que vous l'activiez."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -6558,7 +6564,8 @@
|
||||
"medium": "Moyen",
|
||||
"high": "Élevé"
|
||||
},
|
||||
"coachNote": "Dans le Coach, c'est la profondeur de réflexion des réglages du Coach qui s'applique."
|
||||
"coachNote": "Dans le Coach, c'est la profondeur de réflexion des réglages du Coach qui s'applique.",
|
||||
"backgroundNote": "Les tâches de fond comme le briefing quotidien n'utilisent pas ce réglage : elles réfléchissent au plus en Moyen, moins si l'opérateur fixe une limite plus basse, et pas du tout au niveau Économe."
|
||||
},
|
||||
"providerCardDescription": "Configurez la clé et le modèle de chaque fournisseur ; l’ordre ci-dessous décide lequel HealthLog sollicite en premier.",
|
||||
"runtimeTitle": "Vérifier et actualiser",
|
||||
@@ -7430,6 +7437,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}"
|
||||
@@ -7625,6 +7633,17 @@
|
||||
"passkeyRemovedToast": "Passkey supprimée.",
|
||||
"integrationDisconnected": "{provider} déconnecté.",
|
||||
"integrationDisconnectFailed": "Impossible de déconnecter {provider}.",
|
||||
"preview": {
|
||||
"topics": {
|
||||
"title": "Thèmes",
|
||||
"description": "Ajoute un espace Thèmes qui rassemble les mesures liées, à côté de vos pages Insights.",
|
||||
"detail": "Huit thèmes, du cœur au cycle, montrent vos mesures côte à côte, la façon dont elles évoluent ensemble et des liens vers les pages par mesure que vous utilisez déjà. Vos pages Insights, la navigation et les liens restent exactement tels quels. Tout est calculé à partir de vos propres valeurs, sans IA. Désactiver retire l'entrée et ne supprime rien.",
|
||||
"savedOn": "Aperçu Thèmes activé.",
|
||||
"savedOff": "Aperçu Thèmes désactivé.",
|
||||
"open": "Ouvrir Thèmes"
|
||||
},
|
||||
"loadError": "Impossible de charger vos réglages d'avant-première."
|
||||
},
|
||||
"timezoneSaved": "Fuseau horaire enregistré.",
|
||||
"moduleState": {
|
||||
"on": "Activé",
|
||||
@@ -10426,6 +10445,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)",
|
||||
@@ -10943,25 +10964,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": {
|
||||
@@ -11003,25 +11020,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",
|
||||
@@ -11029,16 +11035,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": {
|
||||
@@ -11068,6 +11064,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": {
|
||||
@@ -11076,7 +11084,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",
|
||||
@@ -11097,51 +11104,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",
|
||||
@@ -11156,6 +11124,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": {
|
||||
@@ -11893,7 +11898,6 @@
|
||||
},
|
||||
"chat": {
|
||||
"title": "Discuter de ce document",
|
||||
"openAria": "Interroger le Coach sur ce document",
|
||||
"openButton": "Demander au Coach"
|
||||
},
|
||||
"readingConsent": {
|
||||
@@ -13811,5 +13815,171 @@
|
||||
"startsOn": "Début",
|
||||
"endsOn": "Fin"
|
||||
}
|
||||
},
|
||||
"topics": {
|
||||
"overview": {
|
||||
"title": "Thèmes",
|
||||
"description": "Des mesures liées, rassemblées à partir de vos propres valeurs ; ouvrez un thème pour les voir côte à côte.",
|
||||
"emptyTitle": "Rien à rassembler pour l'instant",
|
||||
"emptyDescription": "Les thèmes apparaissent dès qu'il y a des mesures pour eux : d'un appareil, d'un import ou saisies par vous.",
|
||||
"notRecorded": "Rien d'enregistré pour l'instant : {topics}.",
|
||||
"hiddenByModule": "Masqués car le module est désactivé : {topics}.",
|
||||
"modulesLink": "Modifier les modules dans les réglages"
|
||||
},
|
||||
"detail": {
|
||||
"back": "Tous les thèmes"
|
||||
},
|
||||
"loadError": "Impossible de charger les thèmes.",
|
||||
"previewNote": "Ceci est un aperçu. Il peut changer d'une version à l'autre, et vos pages par mesure restent telles quelles.",
|
||||
"recorded": "Mesures enregistrées : {count} sur {total}",
|
||||
"tone": {
|
||||
"steady": "Stable",
|
||||
"watch": "À regarder",
|
||||
"learning": "En apprentissage"
|
||||
},
|
||||
"summary": {
|
||||
"outsideAbove": "{signal} est au-dessus de votre plage habituelle.",
|
||||
"outsideBelow": "{signal} est en dessous de votre plage habituelle.",
|
||||
"labs": "{count} analyses sur {total} se situent dans leur plage de référence.",
|
||||
"cycleDay": "Jour {day} du cycle.",
|
||||
"steadyAll": "Tout ce qui est enregistré se situe dans votre plage habituelle.",
|
||||
"steadySome": "{count} mesures sur {total} se situent dans votre plage habituelle ; les autres sont encore en apprentissage.",
|
||||
"learning": "Votre plage habituelle est encore en apprentissage. Une semaine de mesures suffit.",
|
||||
"empty": "Rien d'enregistré pour l'instant."
|
||||
},
|
||||
"headline": {
|
||||
"labsValue": "{count}/{total}",
|
||||
"labsUnit": "dans la plage",
|
||||
"cycleDay": "Jour {day}"
|
||||
},
|
||||
"signals": {
|
||||
"mood": "Humeur"
|
||||
},
|
||||
"units": {
|
||||
"moodScale": "sur 5"
|
||||
},
|
||||
"names": {
|
||||
"heart": {
|
||||
"title": "Cœur",
|
||||
"description": "Fréquence cardiaque au repos, variabilité et condition cardio, avec le sommeil et l'entraînement qui les font varier."
|
||||
},
|
||||
"body": {
|
||||
"title": "Corps",
|
||||
"description": "Poids et composition corporelle dans le temps."
|
||||
},
|
||||
"bloodPressure": {
|
||||
"title": "Tension artérielle",
|
||||
"description": "Systolique, diastolique et pouls, avec le sommeil et l'activité autour."
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sommeil",
|
||||
"description": "Temps de sommeil et mesures prises la nuit."
|
||||
},
|
||||
"activity": {
|
||||
"title": "Activité et entraînement",
|
||||
"description": "Pas, minutes d'exercice, énergie active et charge d'entraînement."
|
||||
},
|
||||
"metabolism": {
|
||||
"title": "Métabolisme et analyses",
|
||||
"description": "Glycémie et vos analyses, avec la tendance de chaque marqueur."
|
||||
},
|
||||
"mind": {
|
||||
"title": "Psychisme",
|
||||
"description": "Humeur et questionnaires à côté du sommeil et de l'activité."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Cycle",
|
||||
"description": "Votre cycle avec la température et la fréquence cardiaque au repos qui le suivent."
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
"heart": "Ces valeurs viennent d'une montre, d'une bague ou d'une ceinture thoracique, d'Apple Santé ou de Health Connect, ou de vos saisies.",
|
||||
"body": "Ces valeurs viennent d'une balance connectée, d'un import ou de vos saisies.",
|
||||
"bloodPressure": "Ces valeurs viennent d'un tensiomètre, d'un import ou de vos saisies.",
|
||||
"sleep": "Ces valeurs viennent d'une montre ou d'une bague, d'Apple Santé ou de Health Connect, ou de vos saisies.",
|
||||
"activity": "Ces valeurs viennent d'un téléphone, d'une montre ou d'une bague, ou d'Apple Santé ou de Health Connect.",
|
||||
"metabolism": "La glycémie vient d'un lecteur, d'un import de capteur ou de vos saisies ; les analyses d'un compte rendu de laboratoire ou de vos saisies.",
|
||||
"mind": "Ces valeurs viennent de vos saisies d'humeur et des questionnaires de la page Bien-être mental.",
|
||||
"cycle": "Ces valeurs viennent de votre journal de cycle et d'un capteur de température au poignet."
|
||||
},
|
||||
"today": {
|
||||
"title": "Aujourd'hui",
|
||||
"usual": "Habituel {low}–{high}",
|
||||
"learning": "Plage habituelle encore en apprentissage",
|
||||
"asOf": "Au {date}",
|
||||
"details": "Détails"
|
||||
},
|
||||
"state": {
|
||||
"within": "Dans la plage",
|
||||
"above": "Au-dessus",
|
||||
"below": "En dessous",
|
||||
"learning": "En apprentissage"
|
||||
},
|
||||
"chart": {
|
||||
"title": "Côte à côte",
|
||||
"legend": "Zone ombrée : votre plage habituelle.",
|
||||
"label": "{signals} sur la période choisie, une ligne chacune."
|
||||
},
|
||||
"range": {
|
||||
"label": "Période",
|
||||
"7d": "7 j",
|
||||
"30d": "30 j",
|
||||
"90d": "90 j",
|
||||
"1y": "1 a"
|
||||
},
|
||||
"relationships": {
|
||||
"title": "Comment elles évoluent ensemble",
|
||||
"insufficient": "{x} et {y} : {count} jours communs sur {required} pour l'instant.",
|
||||
"noClearLink": "Pas de lien net entre {x} et {y} sur {count} jours communs.",
|
||||
"splitHigher": "Quand {x} était sous {threshold}, {y} était plus élevé de {amount} que les autres jours.",
|
||||
"splitLower": "Quand {x} était sous {threshold}, {y} était plus bas de {amount} que les autres jours.",
|
||||
"splitDays": "Jours en dessous : {below}. Autres jours : {other}.",
|
||||
"correlationSameDayHigher": "Quand {x} est plus élevé, {y} tend à l'être aussi (r = {r}).",
|
||||
"correlationSameDayLower": "Quand {x} est plus élevé, {y} tend à être plus bas (r = {r}).",
|
||||
"correlationNextDayHigher": "Quand {x} est plus élevé, {y} tend à être plus élevé le lendemain (r = {r}).",
|
||||
"correlationNextDayLower": "Quand {x} est plus élevé, {y} tend à être plus bas le lendemain (r = {r}).",
|
||||
"pairedDays": "Jours communs : {count}",
|
||||
"footnote": "Affiché dès que deux mesures se recoupent sur au moins 14 jours. Une tendance, pas une cause.",
|
||||
"scatterCaption": "Chaque point est un jour : {x} en abscisse, {y} en ordonnée."
|
||||
},
|
||||
"trends": {
|
||||
"title": "Tendances",
|
||||
"window": "90 jours, moyennes hebdomadaires",
|
||||
"insufficient": "Il faut 14 jours, {count} pour l'instant"
|
||||
},
|
||||
"labs": {
|
||||
"title": "Analyses",
|
||||
"above": "Au-dessus de la plage",
|
||||
"below": "En dessous de la plage",
|
||||
"trendsTitle": "Tendances",
|
||||
"trendNeeds": "Une tendance demande trois valeurs sur au moins un an. Pas encore : {markers}."
|
||||
},
|
||||
"screeners": {
|
||||
"title": "Questionnaires",
|
||||
"none": "Aucun questionnaire rempli pour l'instant.",
|
||||
"checkIns": "Remplis : {count}",
|
||||
"improved": "Mieux que la première fois",
|
||||
"worsened": "Moins bien que la première fois",
|
||||
"within_threshold": "Dans la variation habituelle",
|
||||
"single": "Rempli une fois pour l'instant",
|
||||
"open": "Ouvrir Bien-être mental"
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Cycle",
|
||||
"today": "Jour {day} du cycle.",
|
||||
"todayWithPhase": "Jour {day} du cycle, phase {phase}.",
|
||||
"average": "Durée moyenne du cycle : {days} jours.",
|
||||
"needs": "Une moyenne demande deux débuts de règles enregistrés ; il y en a {count} pour l'instant.",
|
||||
"lengths": "Derniers cycles, en jours : {lengths}",
|
||||
"open": "Ouvrir la page Cycle"
|
||||
},
|
||||
"links": {
|
||||
"title": "Pages par mesure",
|
||||
"workouts": "Entraînements"
|
||||
},
|
||||
"missing": {
|
||||
"title": "Pas encore enregistré : {signals}.",
|
||||
"connect": "Connecter une source"
|
||||
}
|
||||
}
|
||||
}
|
||||
+273
-76
@@ -509,7 +509,9 @@
|
||||
"documents": "Documenti",
|
||||
"vaccinations": "Vaccinazioni",
|
||||
"mentalWellbeing": "Benessere mentale",
|
||||
"timeline": "Cronologia"
|
||||
"timeline": "Cronologia",
|
||||
"topics": "Temi",
|
||||
"previewBadge": "Beta"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Accedi",
|
||||
@@ -1556,11 +1558,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 +3233,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 +3601,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.",
|
||||
@@ -6142,6 +6144,10 @@
|
||||
"access": {
|
||||
"title": "Accesso condiviso",
|
||||
"subtitle": "Chi può aprire la tua cartella sanitaria e quale puoi aprire tu."
|
||||
},
|
||||
"preview": {
|
||||
"title": "Funzioni in anteprima",
|
||||
"subtitle": "Prova ciò che non è ancora finito. Tutto qui resta spento finché non lo attivi."
|
||||
}
|
||||
},
|
||||
"profile": "Profilo",
|
||||
@@ -6558,7 +6564,8 @@
|
||||
"medium": "Medio",
|
||||
"high": "Alto"
|
||||
},
|
||||
"coachNote": "Nel Coach vale la profondità di ragionamento delle impostazioni del Coach."
|
||||
"coachNote": "Nel Coach vale la profondità di ragionamento delle impostazioni del Coach.",
|
||||
"backgroundNote": "Il lavoro in background, come il briefing giornaliero, non usa questa impostazione: ragiona al massimo su Medio, meno se l'operatore imposta un limite più basso, e per niente al livello Risparmio."
|
||||
},
|
||||
"providerCardDescription": "Configura chiave e modello di ogni fornitore; l’ordine qui sotto decide quale HealthLog interpella per primo.",
|
||||
"runtimeTitle": "Verifica e aggiorna",
|
||||
@@ -7430,6 +7437,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}"
|
||||
@@ -7625,6 +7633,17 @@
|
||||
"passkeyRemovedToast": "Passkey rimossa.",
|
||||
"integrationDisconnected": "{provider} disconnesso.",
|
||||
"integrationDisconnectFailed": "Impossibile disconnettere {provider}.",
|
||||
"preview": {
|
||||
"topics": {
|
||||
"title": "Temi",
|
||||
"description": "Aggiunge un'area Temi che raccoglie valori collegati, accanto alle tue pagine Insights.",
|
||||
"detail": "Otto temi, dal cuore al ciclo, mostrano i tuoi valori affiancati, come si muovono insieme e collegamenti alle pagine per metrica che già usi. Le tue pagine Insights, la navigazione e i link restano esattamente come sono. Tutto è calcolato dai tuoi valori, senza IA. Disattivarla toglie la voce e non cancella nulla.",
|
||||
"savedOn": "Anteprima Temi attivata.",
|
||||
"savedOff": "Anteprima Temi disattivata.",
|
||||
"open": "Apri Temi"
|
||||
},
|
||||
"loadError": "Impossibile caricare le impostazioni delle anteprime."
|
||||
},
|
||||
"timezoneSaved": "Fuso orario salvato.",
|
||||
"moduleState": {
|
||||
"on": "Attivo",
|
||||
@@ -10426,6 +10445,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)",
|
||||
@@ -10944,15 +10965,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"
|
||||
},
|
||||
@@ -10961,7 +10988,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": {
|
||||
@@ -11005,23 +11035,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",
|
||||
@@ -11029,16 +11059,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": {
|
||||
@@ -11068,6 +11088,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": {
|
||||
@@ -11076,7 +11108,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",
|
||||
@@ -11097,51 +11128,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",
|
||||
@@ -11156,6 +11151,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": {
|
||||
@@ -11893,7 +11925,6 @@
|
||||
},
|
||||
"chat": {
|
||||
"title": "Chatta su questo documento",
|
||||
"openAria": "Chiedi al Coach su questo documento",
|
||||
"openButton": "Chiedi al Coach"
|
||||
},
|
||||
"readingConsent": {
|
||||
@@ -13811,5 +13842,171 @@
|
||||
"startsOn": "Inizio",
|
||||
"endsOn": "Fine"
|
||||
}
|
||||
},
|
||||
"topics": {
|
||||
"overview": {
|
||||
"title": "Temi",
|
||||
"description": "Valori collegati, raccolti dai tuoi dati; apri un tema per vederli affiancati.",
|
||||
"emptyTitle": "Ancora niente da raccogliere",
|
||||
"emptyDescription": "I temi compaiono appena ci sono valori per loro: da un dispositivo, da un'importazione o inseriti da te.",
|
||||
"notRecorded": "Ancora nessun valore per: {topics}.",
|
||||
"hiddenByModule": "Nascosti perché il modulo è spento: {topics}.",
|
||||
"modulesLink": "Cambia i moduli nelle impostazioni"
|
||||
},
|
||||
"detail": {
|
||||
"back": "Tutti i temi"
|
||||
},
|
||||
"loadError": "Impossibile caricare i temi.",
|
||||
"previewNote": "Questa è un'anteprima. Può cambiare tra una versione e l'altra, e le tue pagine per metrica restano come sono.",
|
||||
"recorded": "Valori registrati: {count} di {total}",
|
||||
"tone": {
|
||||
"steady": "Stabile",
|
||||
"watch": "Da guardare",
|
||||
"learning": "In apprendimento"
|
||||
},
|
||||
"summary": {
|
||||
"outsideAbove": "{signal} è sopra il tuo intervallo abituale.",
|
||||
"outsideBelow": "{signal} è sotto il tuo intervallo abituale.",
|
||||
"labs": "{count} esami su {total} rientrano nel loro intervallo di riferimento.",
|
||||
"cycleDay": "Giorno {day} del ciclo.",
|
||||
"steadyAll": "Tutto ciò che è registrato rientra nel tuo intervallo abituale.",
|
||||
"steadySome": "{count} valori su {total} rientrano nel tuo intervallo abituale; gli altri sono ancora in apprendimento.",
|
||||
"learning": "Il tuo intervallo abituale è ancora in apprendimento. Basta una settimana di valori.",
|
||||
"empty": "Ancora niente di registrato."
|
||||
},
|
||||
"headline": {
|
||||
"labsValue": "{count}/{total}",
|
||||
"labsUnit": "nell'intervallo",
|
||||
"cycleDay": "Giorno {day}"
|
||||
},
|
||||
"signals": {
|
||||
"mood": "Umore"
|
||||
},
|
||||
"units": {
|
||||
"moodScale": "su 5"
|
||||
},
|
||||
"names": {
|
||||
"heart": {
|
||||
"title": "Cuore",
|
||||
"description": "Frequenza cardiaca a riposo, variabilità e forma cardio, con il sonno e l'allenamento che le influenzano."
|
||||
},
|
||||
"body": {
|
||||
"title": "Corpo",
|
||||
"description": "Peso e composizione corporea nel tempo."
|
||||
},
|
||||
"bloodPressure": {
|
||||
"title": "Pressione arteriosa",
|
||||
"description": "Sistolica, diastolica e polso, con il sonno e l'attività intorno."
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sonno",
|
||||
"description": "Tempo di sonno e i valori misurati di notte."
|
||||
},
|
||||
"activity": {
|
||||
"title": "Attività e allenamento",
|
||||
"description": "Passi, minuti di esercizio, energia attiva e carico di allenamento."
|
||||
},
|
||||
"metabolism": {
|
||||
"title": "Metabolismo ed esami",
|
||||
"description": "Glicemia e i tuoi esami, con l'andamento di ogni marcatore."
|
||||
},
|
||||
"mind": {
|
||||
"title": "Mente",
|
||||
"description": "Umore e questionari accanto a sonno e attività."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Ciclo",
|
||||
"description": "Il tuo ciclo con la temperatura e la frequenza cardiaca a riposo che lo seguono."
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
"heart": "Questi valori arrivano da un orologio, un anello o una fascia toracica, da Apple Salute o Health Connect, o dai tuoi inserimenti.",
|
||||
"body": "Questi valori arrivano da una bilancia smart, da un'importazione o dai tuoi inserimenti.",
|
||||
"bloodPressure": "Questi valori arrivano da un misuratore di pressione, da un'importazione o dai tuoi inserimenti.",
|
||||
"sleep": "Questi valori arrivano da un orologio o un anello, da Apple Salute o Health Connect, o dai tuoi inserimenti.",
|
||||
"activity": "Questi valori arrivano da telefono, orologio o anello, o da Apple Salute o Health Connect.",
|
||||
"metabolism": "La glicemia arriva da un glucometro, da un'importazione del sensore o dai tuoi inserimenti; gli esami da un referto o dai tuoi inserimenti.",
|
||||
"mind": "Questi valori arrivano dalle tue registrazioni dell'umore e dai questionari della pagina Benessere mentale.",
|
||||
"cycle": "Questi valori arrivano dal tuo diario del ciclo e da un sensore di temperatura al polso."
|
||||
},
|
||||
"today": {
|
||||
"title": "Oggi",
|
||||
"usual": "Abituale {low}–{high}",
|
||||
"learning": "Intervallo abituale ancora in apprendimento",
|
||||
"asOf": "Al {date}",
|
||||
"details": "Dettagli"
|
||||
},
|
||||
"state": {
|
||||
"within": "Nell'intervallo",
|
||||
"above": "Sopra il solito",
|
||||
"below": "Sotto il solito",
|
||||
"learning": "In apprendimento"
|
||||
},
|
||||
"chart": {
|
||||
"title": "Affiancati",
|
||||
"legend": "Area ombreggiata: il tuo intervallo abituale.",
|
||||
"label": "{signals} nel periodo scelto, una riga ciascuno."
|
||||
},
|
||||
"range": {
|
||||
"label": "Periodo",
|
||||
"7d": "7 g",
|
||||
"30d": "30 g",
|
||||
"90d": "90 g",
|
||||
"1y": "1 a"
|
||||
},
|
||||
"relationships": {
|
||||
"title": "Come si muovono insieme",
|
||||
"insufficient": "{x} e {y}: finora {count} giorni abbinati su {required}.",
|
||||
"noClearLink": "Nessun legame chiaro tra {x} e {y} su {count} giorni abbinati.",
|
||||
"splitHigher": "Quando {x} era sotto {threshold}, {y} era più alto di {amount} rispetto agli altri giorni.",
|
||||
"splitLower": "Quando {x} era sotto {threshold}, {y} era più basso di {amount} rispetto agli altri giorni.",
|
||||
"splitDays": "Giorni sotto: {below}. Altri giorni: {other}.",
|
||||
"correlationSameDayHigher": "Quando {x} è più alto, anche {y} tende a esserlo (r = {r}).",
|
||||
"correlationSameDayLower": "Quando {x} è più alto, {y} tende a essere più basso (r = {r}).",
|
||||
"correlationNextDayHigher": "Quando {x} è più alto, il giorno dopo {y} tende a essere più alto (r = {r}).",
|
||||
"correlationNextDayLower": "Quando {x} è più alto, il giorno dopo {y} tende a essere più basso (r = {r}).",
|
||||
"pairedDays": "Giorni abbinati: {count}",
|
||||
"footnote": "Compare quando due valori si sovrappongono per almeno 14 giorni. Uno schema, non una causa.",
|
||||
"scatterCaption": "Ogni punto è un giorno: {x} in orizzontale, {y} in verticale."
|
||||
},
|
||||
"trends": {
|
||||
"title": "Andamenti",
|
||||
"window": "90 giorni, medie settimanali",
|
||||
"insufficient": "Servono 14 giorni, finora {count}"
|
||||
},
|
||||
"labs": {
|
||||
"title": "Esami",
|
||||
"above": "Sopra l'intervallo",
|
||||
"below": "Sotto l'intervallo",
|
||||
"trendsTitle": "Andamenti",
|
||||
"trendNeeds": "Un andamento richiede tre valori nell'arco di almeno un anno. Non ancora: {markers}."
|
||||
},
|
||||
"screeners": {
|
||||
"title": "Questionari",
|
||||
"none": "Ancora nessun questionario compilato.",
|
||||
"checkIns": "Compilati: {count}",
|
||||
"improved": "Meglio della prima volta",
|
||||
"worsened": "Peggio della prima volta",
|
||||
"within_threshold": "Entro la variazione abituale",
|
||||
"single": "Compilato una volta finora",
|
||||
"open": "Apri Benessere mentale"
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Ciclo",
|
||||
"today": "Giorno {day} del ciclo.",
|
||||
"todayWithPhase": "Giorno {day} del ciclo, fase {phase}.",
|
||||
"average": "Durata media del ciclo: {days} giorni.",
|
||||
"needs": "Una media richiede due inizi di mestruazione registrati; finora sono {count}.",
|
||||
"lengths": "Ultimi cicli, in giorni: {lengths}",
|
||||
"open": "Apri la pagina del ciclo"
|
||||
},
|
||||
"links": {
|
||||
"title": "Pagine per metrica",
|
||||
"workouts": "Allenamenti"
|
||||
},
|
||||
"missing": {
|
||||
"title": "Non ancora registrato: {signals}.",
|
||||
"connect": "Collega una fonte"
|
||||
}
|
||||
}
|
||||
}
|
||||
+246
-116
@@ -509,7 +509,9 @@
|
||||
"documents": "문서",
|
||||
"vaccinations": "예방접종",
|
||||
"mentalWellbeing": "정신 건강",
|
||||
"timeline": "타임라인"
|
||||
"timeline": "타임라인",
|
||||
"topics": "주제",
|
||||
"previewBadge": "베타"
|
||||
},
|
||||
"auth": {
|
||||
"login": "로그인",
|
||||
@@ -3231,11 +3233,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": "대화 보기",
|
||||
@@ -6142,6 +6144,10 @@
|
||||
"access": {
|
||||
"title": "공유 접근",
|
||||
"subtitle": "내 건강 기록을 누가 열 수 있고, 내가 누구의 기록을 열 수 있는지예요."
|
||||
},
|
||||
"preview": {
|
||||
"title": "미리 보기 기능",
|
||||
"subtitle": "아직 완성되지 않은 기능을 써 보세요. 여기 있는 기능은 직접 켜기 전까지 꺼져 있습니다."
|
||||
}
|
||||
},
|
||||
"profile": "프로필",
|
||||
@@ -6558,7 +6564,8 @@
|
||||
"medium": "보통",
|
||||
"high": "높음"
|
||||
},
|
||||
"coachNote": "코치에서는 코치 설정의 사고 깊이가 적용됩니다."
|
||||
"coachNote": "코치에서는 코치 설정의 사고 깊이가 적용됩니다.",
|
||||
"backgroundNote": "일일 브리핑 같은 백그라운드 작업은 이 설정을 쓰지 않습니다. 최대 보통 수준으로 생각하며, 운영자가 더 낮은 한도를 정하면 그보다 낮게, 절약 단계에서는 전혀 생각하지 않습니다."
|
||||
},
|
||||
"providerCardDescription": "제공자마다 키와 모델을 설정하고, 아래 순서로 HealthLog가 먼저 쓸 제공자를 정해요.",
|
||||
"runtimeTitle": "확인 및 새로고침",
|
||||
@@ -7430,6 +7437,7 @@
|
||||
"lastReceived": "마지막 수신 {relative}",
|
||||
"lastNewValue": "마지막 새 값 {relative}",
|
||||
"lastBackground": "마지막 백그라운드 수신 {relative}",
|
||||
"lastDuplicate": "새 값 없이 마지막 재전송 {relative}",
|
||||
"never": "아직 수신되지 않음",
|
||||
"lastBackfill": "기록 보충 {relative}",
|
||||
"lastEnteredHere": "여기에서 마지막 입력 {relative}"
|
||||
@@ -7625,6 +7633,17 @@
|
||||
"passkeyRemovedToast": "패스키를 삭제했어요.",
|
||||
"integrationDisconnected": "{provider} 연결을 해제했어요.",
|
||||
"integrationDisconnectFailed": "{provider} 연결을 해제하지 못했어요.",
|
||||
"preview": {
|
||||
"topics": {
|
||||
"title": "주제",
|
||||
"description": "관련된 수치를 한데 모은 주제 영역을 인사이트 페이지 옆에 추가합니다.",
|
||||
"detail": "심장부터 주기까지 여덟 가지 주제가 수치를 나란히 보여 주고, 함께 어떻게 움직이는지와 이미 쓰고 있는 개별 지표 페이지로 가는 링크를 보여 줍니다. 인사이트 페이지, 내비게이션, 링크는 그대로 유지됩니다. 모든 계산은 AI 없이 본인의 수치로 합니다. 끄면 메뉴 항목만 사라지고 아무것도 삭제되지 않습니다.",
|
||||
"savedOn": "주제 미리 보기를 켰습니다.",
|
||||
"savedOff": "주제 미리 보기를 껐습니다.",
|
||||
"open": "주제 열기"
|
||||
},
|
||||
"loadError": "미리 보기 설정을 불러오지 못했습니다."
|
||||
},
|
||||
"timezoneSaved": "시간대를 저장했습니다.",
|
||||
"moduleState": {
|
||||
"on": "켜짐",
|
||||
@@ -10426,6 +10445,8 @@
|
||||
"yellow-fever": "황열",
|
||||
"japanese-encephalitis": "일본뇌염",
|
||||
"cholera": "콜레라",
|
||||
"dengue": "뎅기열",
|
||||
"tuberculosis": "결핵",
|
||||
"td": "Td(파상풍, 디프테리아)",
|
||||
"tdap": "Tdap(파상풍, 디프테리아, 백일해)",
|
||||
"tdap-ipv": "Tdap-IPV(4종 혼합)",
|
||||
@@ -10943,25 +10964,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": {
|
||||
@@ -11002,45 +11013,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시간 연중무휴, 무료, 비밀 보장)"
|
||||
@@ -11068,6 +11040,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": {
|
||||
@@ -11076,7 +11060,6 @@
|
||||
"who5": "5문항 웰빙 점검이에요. 지난 2주를 기준으로 답해 주세요. 숫자가 클수록 웰빙이 좋아요.",
|
||||
"sci": "8문항 수면 점검이에요. 대부분 지난 한 달을 묻고, 점수가 높을수록 수면이 좋아요."
|
||||
},
|
||||
"progress": "{current} / {total}",
|
||||
"next": "다음",
|
||||
"lastResult": "최근 검사",
|
||||
"lastScore": "최근 결과",
|
||||
@@ -11089,61 +11072,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": "결과가 없다는 뜻은 아니에요. 목록을 가져오지 못했을 뿐이에요. 다시 시도해 주세요.",
|
||||
@@ -11156,6 +11084,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": {
|
||||
@@ -11893,7 +11858,6 @@
|
||||
},
|
||||
"chat": {
|
||||
"title": "이 문서에 대해 대화",
|
||||
"openAria": "이 문서에 대해 코치에게 묻기",
|
||||
"openButton": "코치에게 묻기"
|
||||
},
|
||||
"readingConsent": {
|
||||
@@ -13437,7 +13401,7 @@
|
||||
"empty": "{from}~{to} 기록 없음",
|
||||
"emptySingle": "{period} 기록 없음",
|
||||
"ended": "{label} 끝남",
|
||||
"afterDaysOne": "{count}일 후",
|
||||
"afterDaysOne": "1일 후",
|
||||
"afterDaysFew": "{count}일 후",
|
||||
"afterDaysOther": "{count}일 후",
|
||||
"notable": {
|
||||
@@ -13461,7 +13425,7 @@
|
||||
"apply": "적용"
|
||||
},
|
||||
"item": {
|
||||
"labValuesOne": "검사 수치 {count}개",
|
||||
"labValuesOne": "검사 수치 1개",
|
||||
"labValuesFew": "검사 수치 {count}개",
|
||||
"labValuesOther": "검사 수치 {count}개"
|
||||
}
|
||||
@@ -13811,5 +13775,171 @@
|
||||
"startsOn": "시작",
|
||||
"endsOn": "종료"
|
||||
}
|
||||
},
|
||||
"topics": {
|
||||
"overview": {
|
||||
"title": "주제",
|
||||
"description": "본인의 수치에서 관련 지표를 모았습니다. 주제를 열면 나란히 볼 수 있습니다.",
|
||||
"emptyTitle": "아직 모을 수치가 없습니다",
|
||||
"emptyDescription": "기기, 가져오기 또는 직접 입력으로 수치가 생기면 주제가 나타납니다.",
|
||||
"notRecorded": "아직 기록이 없는 주제: {topics}.",
|
||||
"hiddenByModule": "모듈이 꺼져 있어 숨김: {topics}.",
|
||||
"modulesLink": "설정에서 모듈 변경"
|
||||
},
|
||||
"detail": {
|
||||
"back": "모든 주제"
|
||||
},
|
||||
"loadError": "주제를 불러오지 못했습니다.",
|
||||
"previewNote": "미리 보기 기능입니다. 버전마다 바뀔 수 있으며, 개별 지표 페이지는 그대로입니다.",
|
||||
"recorded": "기록된 지표: {total}개 중 {count}개",
|
||||
"tone": {
|
||||
"steady": "안정적",
|
||||
"watch": "살펴볼 만함",
|
||||
"learning": "학습 중"
|
||||
},
|
||||
"summary": {
|
||||
"outsideAbove": "{signal}이(가) 평소 범위보다 높습니다.",
|
||||
"outsideBelow": "{signal}이(가) 평소 범위보다 낮습니다.",
|
||||
"labs": "검사 수치 {total}개 중 {count}개가 참고 범위 안에 있습니다.",
|
||||
"cycleDay": "주기 {day}일째.",
|
||||
"steadyAll": "기록된 모든 수치가 평소 범위 안에 있습니다.",
|
||||
"steadySome": "{total}개 중 {count}개 지표가 평소 범위 안에 있으며, 나머지는 아직 학습 중입니다.",
|
||||
"learning": "평소 범위를 아직 학습 중입니다. 일주일 치 기록이면 충분합니다.",
|
||||
"empty": "아직 기록이 없습니다."
|
||||
},
|
||||
"headline": {
|
||||
"labsValue": "{count}/{total}",
|
||||
"labsUnit": "범위 내",
|
||||
"cycleDay": "{day}일째"
|
||||
},
|
||||
"signals": {
|
||||
"mood": "기분"
|
||||
},
|
||||
"units": {
|
||||
"moodScale": "/ 5"
|
||||
},
|
||||
"names": {
|
||||
"heart": {
|
||||
"title": "심장",
|
||||
"description": "안정 시 심박수, 심박 변이도, 심폐 체력과 이에 영향을 주는 수면과 운동."
|
||||
},
|
||||
"body": {
|
||||
"title": "신체",
|
||||
"description": "시간에 따른 체중과 체성분."
|
||||
},
|
||||
"bloodPressure": {
|
||||
"title": "혈압",
|
||||
"description": "수축기, 이완기 혈압과 맥박, 그리고 주변의 수면과 활동."
|
||||
},
|
||||
"sleep": {
|
||||
"title": "수면",
|
||||
"description": "수면 시간과 밤에 측정되는 수치."
|
||||
},
|
||||
"activity": {
|
||||
"title": "활동 및 운동",
|
||||
"description": "걸음 수, 운동 시간, 활동 에너지와 훈련 부하."
|
||||
},
|
||||
"metabolism": {
|
||||
"title": "대사 및 검사",
|
||||
"description": "혈당과 검사 수치, 그리고 각 지표의 추세."
|
||||
},
|
||||
"mind": {
|
||||
"title": "마음",
|
||||
"description": "수면과 활동 옆에 놓인 기분과 설문."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "주기",
|
||||
"description": "주기와 이를 따라 변하는 체온, 안정 시 심박수."
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
"heart": "이 수치는 워치, 링, 가슴 스트랩, Apple 건강 또는 Health Connect, 직접 입력에서 옵니다.",
|
||||
"body": "이 수치는 스마트 체중계, 가져오기 또는 직접 입력에서 옵니다.",
|
||||
"bloodPressure": "이 수치는 혈압계, 가져오기 또는 직접 입력에서 옵니다.",
|
||||
"sleep": "이 수치는 워치나 링, Apple 건강 또는 Health Connect, 직접 입력에서 옵니다.",
|
||||
"activity": "이 수치는 휴대폰, 워치, 링 또는 Apple 건강이나 Health Connect에서 옵니다.",
|
||||
"metabolism": "혈당은 혈당 측정기, 센서 가져오기 또는 직접 입력에서, 검사 수치는 검사 보고서나 직접 입력에서 옵니다.",
|
||||
"mind": "이 수치는 기분 기록과 정신 건강 페이지의 설문에서 옵니다.",
|
||||
"cycle": "이 수치는 주기 기록과 손목 온도 센서에서 옵니다."
|
||||
},
|
||||
"today": {
|
||||
"title": "오늘",
|
||||
"usual": "평소 {low}–{high}",
|
||||
"learning": "평소 범위 학습 중",
|
||||
"asOf": "{date} 기준",
|
||||
"details": "자세히"
|
||||
},
|
||||
"state": {
|
||||
"within": "범위 내",
|
||||
"above": "평소보다 높음",
|
||||
"below": "평소보다 낮음",
|
||||
"learning": "학습 중"
|
||||
},
|
||||
"chart": {
|
||||
"title": "나란히 보기",
|
||||
"legend": "음영: 평소 범위.",
|
||||
"label": "선택한 기간의 {signals}, 지표마다 한 줄."
|
||||
},
|
||||
"range": {
|
||||
"label": "기간",
|
||||
"7d": "7일",
|
||||
"30d": "30일",
|
||||
"90d": "90일",
|
||||
"1y": "1년"
|
||||
},
|
||||
"relationships": {
|
||||
"title": "함께 움직이는 방식",
|
||||
"insufficient": "{x}와(과) {y}: 지금까지 {required}일 중 {count}일이 겹칩니다.",
|
||||
"noClearLink": "겹치는 {count}일 동안 {x}와(과) {y} 사이에 뚜렷한 관계가 없습니다.",
|
||||
"splitHigher": "{x}이(가) {threshold} 미만일 때 {y}이(가) 다른 날보다 {amount} 높았습니다.",
|
||||
"splitLower": "{x}이(가) {threshold} 미만일 때 {y}이(가) 다른 날보다 {amount} 낮았습니다.",
|
||||
"splitDays": "미만인 날: {below}. 다른 날: {other}.",
|
||||
"correlationSameDayHigher": "{x}이(가) 높을수록 {y}도 높은 경향이 있습니다 (r = {r}).",
|
||||
"correlationSameDayLower": "{x}이(가) 높을수록 {y}은(는) 낮은 경향이 있습니다 (r = {r}).",
|
||||
"correlationNextDayHigher": "{x}이(가) 높으면 다음 날 {y}도 높은 경향이 있습니다 (r = {r}).",
|
||||
"correlationNextDayLower": "{x}이(가) 높으면 다음 날 {y}은(는) 낮은 경향이 있습니다 (r = {r}).",
|
||||
"pairedDays": "겹치는 날: {count}",
|
||||
"footnote": "두 지표가 14일 이상 겹치면 표시됩니다. 패턴일 뿐 원인이 아닙니다.",
|
||||
"scatterCaption": "점 하나가 하루입니다. 가로는 {x}, 세로는 {y}."
|
||||
},
|
||||
"trends": {
|
||||
"title": "추세",
|
||||
"window": "90일, 주간 평균",
|
||||
"insufficient": "14일이 필요합니다. 지금까지 {count}일"
|
||||
},
|
||||
"labs": {
|
||||
"title": "검사 수치",
|
||||
"above": "범위 초과",
|
||||
"below": "범위 미만",
|
||||
"trendsTitle": "추세",
|
||||
"trendNeeds": "추세에는 1년 이상에 걸친 수치 세 개가 필요합니다. 아직 부족: {markers}."
|
||||
},
|
||||
"screeners": {
|
||||
"title": "설문",
|
||||
"none": "아직 작성한 설문이 없습니다.",
|
||||
"checkIns": "작성 횟수: {count}",
|
||||
"improved": "처음보다 좋아짐",
|
||||
"worsened": "처음보다 나빠짐",
|
||||
"within_threshold": "평소 변동 범위 내",
|
||||
"single": "지금까지 한 번 작성",
|
||||
"open": "정신 건강 열기"
|
||||
},
|
||||
"cycle": {
|
||||
"title": "주기",
|
||||
"today": "주기 {day}일째.",
|
||||
"todayWithPhase": "주기 {day}일째, {phase}.",
|
||||
"average": "평균 주기 길이: {days}일.",
|
||||
"needs": "평균을 내려면 생리 시작일 기록이 두 번 필요합니다. 지금까지 {count}번입니다.",
|
||||
"lengths": "최근 주기(일): {lengths}",
|
||||
"open": "주기 페이지 열기"
|
||||
},
|
||||
"links": {
|
||||
"title": "개별 지표 페이지",
|
||||
"workouts": "운동"
|
||||
},
|
||||
"missing": {
|
||||
"title": "아직 기록되지 않음: {signals}.",
|
||||
"connect": "데이터 소스 연결"
|
||||
}
|
||||
}
|
||||
}
|
||||
+244
-114
@@ -509,7 +509,9 @@
|
||||
"documents": "Documenten",
|
||||
"vaccinations": "Vaccinaties",
|
||||
"mentalWellbeing": "Mentaal welzijn",
|
||||
"timeline": "Tijdlijn"
|
||||
"timeline": "Tijdlijn",
|
||||
"topics": "Thema's",
|
||||
"previewBadge": "Bèta"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Inloggen",
|
||||
@@ -3231,11 +3233,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",
|
||||
@@ -6142,6 +6144,10 @@
|
||||
"access": {
|
||||
"title": "Gedeelde toegang",
|
||||
"subtitle": "Wie je gezondheidsdossier kan openen, en wiens dossier jij kunt openen."
|
||||
},
|
||||
"preview": {
|
||||
"title": "Voorvertoningen",
|
||||
"subtitle": "Probeer wat nog niet af is. Alles hier blijft uit tot je het aanzet."
|
||||
}
|
||||
},
|
||||
"profile": "Profiel",
|
||||
@@ -6558,7 +6564,8 @@
|
||||
"medium": "Gemiddeld",
|
||||
"high": "Hoog"
|
||||
},
|
||||
"coachNote": "In de Coach geldt de denkdiepte uit de Coach-instellingen."
|
||||
"coachNote": "In de Coach geldt de denkdiepte uit de Coach-instellingen.",
|
||||
"backgroundNote": "Achtergrondwerk zoals de dagelijkse briefing gebruikt deze instelling niet: het denkt hoogstens op Gemiddeld na, minder als de beheerder een lagere grens instelt, en helemaal niet op het niveau Zuinig."
|
||||
},
|
||||
"providerCardDescription": "Stel per provider de sleutel en het model in; de volgorde hieronder bepaalt welke HealthLog als eerste raadpleegt.",
|
||||
"runtimeTitle": "Controleren en vernieuwen",
|
||||
@@ -7430,6 +7437,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}"
|
||||
@@ -7625,6 +7633,17 @@
|
||||
"passkeyRemovedToast": "Passkey verwijderd.",
|
||||
"integrationDisconnected": "{provider} ontkoppeld.",
|
||||
"integrationDisconnectFailed": "{provider} kon niet worden ontkoppeld.",
|
||||
"preview": {
|
||||
"topics": {
|
||||
"title": "Thema's",
|
||||
"description": "Voegt een gedeelte Thema's toe dat samenhangende waarden bundelt, naast je Inzichten-pagina's.",
|
||||
"detail": "Acht thema's, van hart tot cyclus, tonen je waarden naast elkaar, hoe ze samen bewegen en links naar de pagina's per meetwaarde die je al gebruikt. Je Inzichten-pagina's, de navigatie en alle links blijven precies zoals ze zijn. Alles wordt uit je eigen waarden berekend, zonder AI. Uitzetten haalt de ingang weg en verwijdert niets.",
|
||||
"savedOn": "Voorvertoning Thema's aangezet.",
|
||||
"savedOff": "Voorvertoning Thema's uitgezet.",
|
||||
"open": "Thema's openen"
|
||||
},
|
||||
"loadError": "Je voorvertoningsinstellingen konden niet worden geladen."
|
||||
},
|
||||
"timezoneSaved": "Tijdzone opgeslagen.",
|
||||
"moduleState": {
|
||||
"on": "Aan",
|
||||
@@ -10426,6 +10445,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)",
|
||||
@@ -10943,25 +10964,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": {
|
||||
@@ -11002,45 +11013,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)"
|
||||
@@ -11068,6 +11040,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": {
|
||||
@@ -11076,7 +11060,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",
|
||||
@@ -11089,61 +11072,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.",
|
||||
@@ -11156,6 +11084,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": {
|
||||
@@ -11893,7 +11858,6 @@
|
||||
},
|
||||
"chat": {
|
||||
"title": "Chatten over dit document",
|
||||
"openAria": "De Coach een vraag stellen over dit document",
|
||||
"openButton": "Vraag het de Coach"
|
||||
},
|
||||
"readingConsent": {
|
||||
@@ -13811,5 +13775,171 @@
|
||||
"startsOn": "Begin",
|
||||
"endsOn": "Einde"
|
||||
}
|
||||
},
|
||||
"topics": {
|
||||
"overview": {
|
||||
"title": "Thema's",
|
||||
"description": "Samenhangende waarden, gebundeld uit je eigen gegevens; open een thema om ze naast elkaar te zien.",
|
||||
"emptyTitle": "Nog niets om te bundelen",
|
||||
"emptyDescription": "Thema's verschijnen zodra er waarden voor zijn: van een apparaat, uit een import of door jou ingevoerd.",
|
||||
"notRecorded": "Nog niets vastgelegd voor: {topics}.",
|
||||
"hiddenByModule": "Verborgen omdat de module uit staat: {topics}.",
|
||||
"modulesLink": "Modules wijzigen in de instellingen"
|
||||
},
|
||||
"detail": {
|
||||
"back": "Alle thema's"
|
||||
},
|
||||
"loadError": "De thema's konden niet worden geladen.",
|
||||
"previewNote": "Dit is een voorvertoning. Die kan per versie veranderen, en je pagina's per meetwaarde blijven zoals ze zijn.",
|
||||
"recorded": "Vastgelegde waarden: {count} van {total}",
|
||||
"tone": {
|
||||
"steady": "Stabiel",
|
||||
"watch": "Het bekijken waard",
|
||||
"learning": "Leert nog"
|
||||
},
|
||||
"summary": {
|
||||
"outsideAbove": "{signal} ligt boven je gebruikelijke bereik.",
|
||||
"outsideBelow": "{signal} ligt onder je gebruikelijke bereik.",
|
||||
"labs": "{count} van {total} labwaarden liggen binnen hun referentiebereik.",
|
||||
"cycleDay": "Cyclusdag {day}.",
|
||||
"steadyAll": "Alles wat is vastgelegd ligt binnen je gebruikelijke bereik.",
|
||||
"steadySome": "{count} van {total} waarden liggen binnen je gebruikelijke bereik; de andere worden nog geleerd.",
|
||||
"learning": "Je gebruikelijke bereik wordt nog geleerd. Een week aan metingen is genoeg.",
|
||||
"empty": "Nog niets vastgelegd."
|
||||
},
|
||||
"headline": {
|
||||
"labsValue": "{count}/{total}",
|
||||
"labsUnit": "binnen bereik",
|
||||
"cycleDay": "Dag {day}"
|
||||
},
|
||||
"signals": {
|
||||
"mood": "Stemming"
|
||||
},
|
||||
"units": {
|
||||
"moodScale": "van 5"
|
||||
},
|
||||
"names": {
|
||||
"heart": {
|
||||
"title": "Hart",
|
||||
"description": "Rusthartslag, hartslagvariabiliteit en conditie, met de slaap en training die ze beïnvloeden."
|
||||
},
|
||||
"body": {
|
||||
"title": "Lichaam",
|
||||
"description": "Gewicht en lichaamssamenstelling door de tijd."
|
||||
},
|
||||
"bloodPressure": {
|
||||
"title": "Bloeddruk",
|
||||
"description": "Systolisch, diastolisch en hartslag, met de slaap en activiteit eromheen."
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Slaap",
|
||||
"description": "Slaapduur en de waarden die 's nachts worden gemeten."
|
||||
},
|
||||
"activity": {
|
||||
"title": "Activiteit en training",
|
||||
"description": "Stappen, trainingsminuten, actieve energie en trainingsbelasting."
|
||||
},
|
||||
"metabolism": {
|
||||
"title": "Stofwisseling en lab",
|
||||
"description": "Bloedglucose en je labwaarden, met de trend van elke marker."
|
||||
},
|
||||
"mind": {
|
||||
"title": "Geest",
|
||||
"description": "Stemming en vragenlijsten naast slaap en activiteit."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Cyclus",
|
||||
"description": "Je cyclus met de temperatuur en rusthartslag die ermee meebewegen."
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
"heart": "Deze waarden komen van een horloge, ring of borstband, uit Apple Gezondheid of Health Connect, of van je eigen invoer.",
|
||||
"body": "Deze waarden komen van een slimme weegschaal, een import of je eigen invoer.",
|
||||
"bloodPressure": "Deze waarden komen van een bloeddrukmeter, een import of je eigen invoer.",
|
||||
"sleep": "Deze waarden komen van een horloge of ring, uit Apple Gezondheid of Health Connect, of van je eigen invoer.",
|
||||
"activity": "Deze waarden komen van een telefoon, horloge of ring, of uit Apple Gezondheid of Health Connect.",
|
||||
"metabolism": "Glucose komt van een meter, een sensorimport of je eigen invoer; labwaarden uit een labrapport of je eigen invoer.",
|
||||
"mind": "Deze waarden komen uit je stemmingsregistraties en de vragenlijsten op de pagina Mentaal welzijn.",
|
||||
"cycle": "Deze waarden komen uit je cycluslogboek en een temperatuursensor aan de pols."
|
||||
},
|
||||
"today": {
|
||||
"title": "Vandaag",
|
||||
"usual": "Gebruikelijk {low}–{high}",
|
||||
"learning": "Gebruikelijk bereik wordt nog geleerd",
|
||||
"asOf": "Per {date}",
|
||||
"details": "Details"
|
||||
},
|
||||
"state": {
|
||||
"within": "Binnen bereik",
|
||||
"above": "Boven gebruikelijk",
|
||||
"below": "Onder gebruikelijk",
|
||||
"learning": "Leert nog"
|
||||
},
|
||||
"chart": {
|
||||
"title": "Naast elkaar",
|
||||
"legend": "Gearceerd: je gebruikelijke bereik.",
|
||||
"label": "{signals} in de gekozen periode, elk op een eigen rij."
|
||||
},
|
||||
"range": {
|
||||
"label": "Periode",
|
||||
"7d": "7 d",
|
||||
"30d": "30 d",
|
||||
"90d": "90 d",
|
||||
"1y": "1 j"
|
||||
},
|
||||
"relationships": {
|
||||
"title": "Hoe ze samen bewegen",
|
||||
"insufficient": "{x} en {y}: tot nu toe {count} van {required} gedeelde dagen.",
|
||||
"noClearLink": "Geen duidelijk verband tussen {x} en {y} over {count} gedeelde dagen.",
|
||||
"splitHigher": "Als {x} onder {threshold} lag, was {y} {amount} hoger dan op andere dagen.",
|
||||
"splitLower": "Als {x} onder {threshold} lag, was {y} {amount} lager dan op andere dagen.",
|
||||
"splitDays": "Dagen eronder: {below}. Andere dagen: {other}.",
|
||||
"correlationSameDayHigher": "Als {x} hoger is, is {y} meestal ook hoger (r = {r}).",
|
||||
"correlationSameDayLower": "Als {x} hoger is, is {y} meestal lager (r = {r}).",
|
||||
"correlationNextDayHigher": "Als {x} hoger is, is {y} de volgende dag meestal hoger (r = {r}).",
|
||||
"correlationNextDayLower": "Als {x} hoger is, is {y} de volgende dag meestal lager (r = {r}).",
|
||||
"pairedDays": "Gedeelde dagen: {count}",
|
||||
"footnote": "Verschijnt zodra twee waarden op minstens 14 dagen samenvallen. Een patroon, geen oorzaak.",
|
||||
"scatterCaption": "Elke stip is één dag: {x} horizontaal, {y} verticaal."
|
||||
},
|
||||
"trends": {
|
||||
"title": "Trends",
|
||||
"window": "90 dagen, weekgemiddelden",
|
||||
"insufficient": "Heeft 14 dagen nodig, nu {count}"
|
||||
},
|
||||
"labs": {
|
||||
"title": "Labwaarden",
|
||||
"above": "Boven het bereik",
|
||||
"below": "Onder het bereik",
|
||||
"trendsTitle": "Trends",
|
||||
"trendNeeds": "Een trend heeft drie waarden over minstens een jaar nodig. Nog niet: {markers}."
|
||||
},
|
||||
"screeners": {
|
||||
"title": "Vragenlijsten",
|
||||
"none": "Nog geen vragenlijsten ingevuld.",
|
||||
"checkIns": "Ingevuld: {count}",
|
||||
"improved": "Beter dan de eerste keer",
|
||||
"worsened": "Slechter dan de eerste keer",
|
||||
"within_threshold": "Binnen de gebruikelijke schommeling",
|
||||
"single": "Tot nu toe één keer ingevuld",
|
||||
"open": "Mentaal welzijn openen"
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Cyclus",
|
||||
"today": "Cyclusdag {day}.",
|
||||
"todayWithPhase": "Cyclusdag {day}, {phase}.",
|
||||
"average": "Gemiddelde cycluslengte: {days} dagen.",
|
||||
"needs": "Een gemiddelde heeft twee vastgelegde menstruatiestarts nodig; nu zijn het er {count}.",
|
||||
"lengths": "Recente cycli in dagen: {lengths}",
|
||||
"open": "Cycluspagina openen"
|
||||
},
|
||||
"links": {
|
||||
"title": "Pagina's per meetwaarde",
|
||||
"workouts": "Workouts"
|
||||
},
|
||||
"missing": {
|
||||
"title": "Nog niet vastgelegd: {signals}.",
|
||||
"connect": "Een bron koppelen"
|
||||
}
|
||||
}
|
||||
}
|
||||
+281
-84
@@ -509,7 +509,9 @@
|
||||
"documents": "Dokumenty",
|
||||
"vaccinations": "Szczepienia",
|
||||
"mentalWellbeing": "Dobrostan psychiczny",
|
||||
"timeline": "Oś czasu"
|
||||
"timeline": "Oś czasu",
|
||||
"topics": "Tematy",
|
||||
"previewBadge": "Beta"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Zaloguj się",
|
||||
@@ -1556,13 +1558,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 +3233,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 +3601,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.",
|
||||
@@ -6142,6 +6144,10 @@
|
||||
"access": {
|
||||
"title": "Udostępniony dostęp",
|
||||
"subtitle": "Kto może otworzyć twoją kartę zdrowia i czyją kartę możesz otworzyć ty."
|
||||
},
|
||||
"preview": {
|
||||
"title": "Funkcje w wersji próbnej",
|
||||
"subtitle": "Wypróbuj to, co jeszcze nie jest gotowe. Wszystko tutaj pozostaje wyłączone, dopóki tego nie włączysz."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -6558,7 +6564,8 @@
|
||||
"medium": "Średnie",
|
||||
"high": "Wysokie"
|
||||
},
|
||||
"coachNote": "W Coachu obowiązuje głębokość myślenia z ustawień Coacha."
|
||||
"coachNote": "W Coachu obowiązuje głębokość myślenia z ustawień Coacha.",
|
||||
"backgroundNote": "Praca w tle, taka jak codzienne podsumowanie, nie korzysta z tego ustawienia: myśli najwyżej na poziomie Średnie, niżej, gdy operator ustawi niższy limit, a na poziomie Oszczędny wcale."
|
||||
},
|
||||
"providerCardDescription": "Skonfiguruj klucz i model każdego dostawcy; kolejność poniżej decyduje, którego HealthLog pyta najpierw.",
|
||||
"runtimeTitle": "Sprawdź i odśwież",
|
||||
@@ -7430,6 +7437,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}"
|
||||
@@ -7625,6 +7633,17 @@
|
||||
"passkeyRemovedToast": "Usunięto passkey.",
|
||||
"integrationDisconnected": "Rozłączono {provider}.",
|
||||
"integrationDisconnectFailed": "Nie udało się rozłączyć {provider}.",
|
||||
"preview": {
|
||||
"topics": {
|
||||
"title": "Tematy",
|
||||
"description": "Dodaje obszar Tematy, który zbiera powiązane wartości, obok Twoich stron Insights.",
|
||||
"detail": "Osiem tematów, od serca po cykl, pokazuje Twoje wartości obok siebie, jak zmieniają się razem, oraz linki do stron pojedynczych wskaźników, których już używasz. Strony Insights, nawigacja i linki zostają dokładnie takie, jakie są. Wszystko jest liczone z Twoich własnych wartości, bez AI. Wyłączenie usuwa wpis i niczego nie kasuje.",
|
||||
"savedOn": "Wersja próbna Tematów włączona.",
|
||||
"savedOff": "Wersja próbna Tematów wyłączona.",
|
||||
"open": "Otwórz Tematy"
|
||||
},
|
||||
"loadError": "Nie udało się wczytać ustawień wersji próbnych."
|
||||
},
|
||||
"timezoneSaved": "Strefa czasowa zapisana.",
|
||||
"moduleState": {
|
||||
"on": "Włączone",
|
||||
@@ -10426,6 +10445,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)",
|
||||
@@ -10944,24 +10965,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": {
|
||||
@@ -11005,23 +11035,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",
|
||||
@@ -11029,16 +11059,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": {
|
||||
@@ -11068,6 +11088,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": {
|
||||
@@ -11076,7 +11108,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",
|
||||
@@ -11097,51 +11128,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",
|
||||
@@ -11156,6 +11151,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": {
|
||||
@@ -11893,7 +11925,6 @@
|
||||
},
|
||||
"chat": {
|
||||
"title": "Porozmawiaj o tym dokumencie",
|
||||
"openAria": "Zapytaj Coacha o ten dokument",
|
||||
"openButton": "Zapytaj Coacha"
|
||||
},
|
||||
"readingConsent": {
|
||||
@@ -13811,5 +13842,171 @@
|
||||
"startsOn": "Początek",
|
||||
"endsOn": "Koniec"
|
||||
}
|
||||
},
|
||||
"topics": {
|
||||
"overview": {
|
||||
"title": "Tematy",
|
||||
"description": "Powiązane wartości zebrane z Twoich danych; otwórz temat, aby zobaczyć je obok siebie.",
|
||||
"emptyTitle": "Na razie nie ma czego zebrać",
|
||||
"emptyDescription": "Tematy pojawiają się, gdy są dla nich wartości: z urządzenia, z importu lub wpisane przez Ciebie.",
|
||||
"notRecorded": "Brak jeszcze wartości dla: {topics}.",
|
||||
"hiddenByModule": "Ukryte, bo moduł jest wyłączony: {topics}.",
|
||||
"modulesLink": "Zmień moduły w ustawieniach"
|
||||
},
|
||||
"detail": {
|
||||
"back": "Wszystkie tematy"
|
||||
},
|
||||
"loadError": "Nie udało się wczytać tematów.",
|
||||
"previewNote": "To wersja próbna. Może się zmieniać między wydaniami, a strony pojedynczych wskaźników zostają bez zmian.",
|
||||
"recorded": "Zapisane wartości: {count} z {total}",
|
||||
"tone": {
|
||||
"steady": "Stabilnie",
|
||||
"watch": "Warto zerknąć",
|
||||
"learning": "W trakcie nauki"
|
||||
},
|
||||
"summary": {
|
||||
"outsideAbove": "{signal} jest powyżej Twojego zwykłego zakresu.",
|
||||
"outsideBelow": "{signal} jest poniżej Twojego zwykłego zakresu.",
|
||||
"labs": "{count} z {total} wyników badań mieści się w zakresie referencyjnym.",
|
||||
"cycleDay": "Dzień cyklu {day}.",
|
||||
"steadyAll": "Wszystko, co zapisane, mieści się w Twoim zwykłym zakresie.",
|
||||
"steadySome": "{count} z {total} wartości mieści się w Twoim zwykłym zakresie; pozostałe są jeszcze poznawane.",
|
||||
"learning": "Twój zwykły zakres jest jeszcze poznawany. Wystarczy tydzień pomiarów.",
|
||||
"empty": "Nic jeszcze nie zapisano."
|
||||
},
|
||||
"headline": {
|
||||
"labsValue": "{count}/{total}",
|
||||
"labsUnit": "w zakresie",
|
||||
"cycleDay": "Dzień {day}"
|
||||
},
|
||||
"signals": {
|
||||
"mood": "Nastrój"
|
||||
},
|
||||
"units": {
|
||||
"moodScale": "z 5"
|
||||
},
|
||||
"names": {
|
||||
"heart": {
|
||||
"title": "Serce",
|
||||
"description": "Tętno spoczynkowe, zmienność rytmu serca i wydolność, wraz ze snem i treningiem, które na nie wpływają."
|
||||
},
|
||||
"body": {
|
||||
"title": "Ciało",
|
||||
"description": "Masa ciała i skład ciała w czasie."
|
||||
},
|
||||
"bloodPressure": {
|
||||
"title": "Ciśnienie krwi",
|
||||
"description": "Skurczowe, rozkurczowe i tętno, wraz ze snem i aktywnością wokół nich."
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sen",
|
||||
"description": "Czas snu i wartości mierzone w nocy."
|
||||
},
|
||||
"activity": {
|
||||
"title": "Aktywność i trening",
|
||||
"description": "Kroki, minuty ćwiczeń, aktywna energia i obciążenie treningowe."
|
||||
},
|
||||
"metabolism": {
|
||||
"title": "Metabolizm i badania",
|
||||
"description": "Glukoza i Twoje wyniki badań, z trendem każdego markera."
|
||||
},
|
||||
"mind": {
|
||||
"title": "Psychika",
|
||||
"description": "Nastrój i kwestionariusze obok snu i aktywności."
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Cykl",
|
||||
"description": "Twój cykl wraz z temperaturą i tętnem spoczynkowym, które za nim podążają."
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
"heart": "Te wartości pochodzą z zegarka, pierścienia lub pasa piersiowego, z Apple Zdrowie lub Health Connect albo z Twoich wpisów.",
|
||||
"body": "Te wartości pochodzą z inteligentnej wagi, z importu lub z Twoich wpisów.",
|
||||
"bloodPressure": "Te wartości pochodzą z ciśnieniomierza, z importu lub z Twoich wpisów.",
|
||||
"sleep": "Te wartości pochodzą z zegarka lub pierścienia, z Apple Zdrowie lub Health Connect albo z Twoich wpisów.",
|
||||
"activity": "Te wartości pochodzą z telefonu, zegarka lub pierścienia albo z Apple Zdrowie lub Health Connect.",
|
||||
"metabolism": "Glukoza pochodzi z glukometru, importu z sensora lub Twoich wpisów; wyniki badań z raportu laboratoryjnego lub Twoich wpisów.",
|
||||
"mind": "Te wartości pochodzą z Twoich wpisów nastroju i kwestionariuszy na stronie Dobrostan psychiczny.",
|
||||
"cycle": "Te wartości pochodzą z Twojego dziennika cyklu i czujnika temperatury na nadgarstku."
|
||||
},
|
||||
"today": {
|
||||
"title": "Dziś",
|
||||
"usual": "Zwykle {low}–{high}",
|
||||
"learning": "Zwykły zakres jest jeszcze poznawany",
|
||||
"asOf": "Stan na {date}",
|
||||
"details": "Szczegóły"
|
||||
},
|
||||
"state": {
|
||||
"within": "W zakresie",
|
||||
"above": "Powyżej zwykłego",
|
||||
"below": "Poniżej zwykłego",
|
||||
"learning": "W trakcie nauki"
|
||||
},
|
||||
"chart": {
|
||||
"title": "Obok siebie",
|
||||
"legend": "Zacieniowane: Twój zwykły zakres.",
|
||||
"label": "{signals} w wybranym okresie, każdy w osobnym wierszu."
|
||||
},
|
||||
"range": {
|
||||
"label": "Okres",
|
||||
"7d": "7 d",
|
||||
"30d": "30 d",
|
||||
"90d": "90 d",
|
||||
"1y": "1 r"
|
||||
},
|
||||
"relationships": {
|
||||
"title": "Jak zmieniają się razem",
|
||||
"insufficient": "{x} i {y}: na razie {count} z {required} wspólnych dni.",
|
||||
"noClearLink": "Brak wyraźnego związku między {x} a {y} w ciągu {count} wspólnych dni.",
|
||||
"splitHigher": "Gdy {x} było poniżej {threshold}, {y} było o {amount} wyższe niż w inne dni.",
|
||||
"splitLower": "Gdy {x} było poniżej {threshold}, {y} było o {amount} niższe niż w inne dni.",
|
||||
"splitDays": "Dni poniżej: {below}. Pozostałe dni: {other}.",
|
||||
"correlationSameDayHigher": "Gdy {x} jest wyższe, {y} zwykle też jest wyższe (r = {r}).",
|
||||
"correlationSameDayLower": "Gdy {x} jest wyższe, {y} zwykle jest niższe (r = {r}).",
|
||||
"correlationNextDayHigher": "Gdy {x} jest wyższe, następnego dnia {y} zwykle jest wyższe (r = {r}).",
|
||||
"correlationNextDayLower": "Gdy {x} jest wyższe, następnego dnia {y} zwykle jest niższe (r = {r}).",
|
||||
"pairedDays": "Wspólne dni: {count}",
|
||||
"footnote": "Pokazywane, gdy dwie wartości pokrywają się przez co najmniej 14 dni. Wzorzec, nie przyczyna.",
|
||||
"scatterCaption": "Każdy punkt to jeden dzień: {x} w poziomie, {y} w pionie."
|
||||
},
|
||||
"trends": {
|
||||
"title": "Trendy",
|
||||
"window": "90 dni, średnie tygodniowe",
|
||||
"insufficient": "Potrzeba 14 dni, na razie {count}"
|
||||
},
|
||||
"labs": {
|
||||
"title": "Wyniki badań",
|
||||
"above": "Powyżej zakresu",
|
||||
"below": "Poniżej zakresu",
|
||||
"trendsTitle": "Trendy",
|
||||
"trendNeeds": "Trend wymaga trzech wartości w ciągu co najmniej roku. Jeszcze nie: {markers}."
|
||||
},
|
||||
"screeners": {
|
||||
"title": "Kwestionariusze",
|
||||
"none": "Nie wypełniono jeszcze żadnego kwestionariusza.",
|
||||
"checkIns": "Wypełnione: {count}",
|
||||
"improved": "Lepiej niż za pierwszym razem",
|
||||
"worsened": "Gorzej niż za pierwszym razem",
|
||||
"within_threshold": "W granicach zwykłych wahań",
|
||||
"single": "Na razie wypełniony raz",
|
||||
"open": "Otwórz Dobrostan psychiczny"
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Cykl",
|
||||
"today": "Dzień cyklu {day}.",
|
||||
"todayWithPhase": "Dzień cyklu {day}, faza: {phase}.",
|
||||
"average": "Średnia długość cyklu: {days} dni.",
|
||||
"needs": "Średnia wymaga dwóch zapisanych początków miesiączki; na razie jest ich {count}.",
|
||||
"lengths": "Ostatnie cykle w dniach: {lengths}",
|
||||
"open": "Otwórz stronę cyklu"
|
||||
},
|
||||
"links": {
|
||||
"title": "Strony wskaźników",
|
||||
"workouts": "Treningi"
|
||||
},
|
||||
"missing": {
|
||||
"title": "Jeszcze nie zapisano: {signals}.",
|
||||
"connect": "Połącz źródło"
|
||||
}
|
||||
}
|
||||
}
|
||||
+13945
File diff suppressed because it is too large.
Load diff
@@ -35,6 +35,16 @@ const nextConfig: NextConfig = {
|
||||
// still covers the full program.
|
||||
typescript: { tsconfigPath: "tsconfig.build.json" },
|
||||
poweredByHeader: false,
|
||||
// The image optimizer is off. Nothing renders through `next/image`: logos,
|
||||
// icons and avatars are plain `<img>` elements served from `public/` or
|
||||
// from an authenticated route. With the optimizer on, `/_next/image` still
|
||||
// fetched, decoded and re-encoded whatever local path a request named,
|
||||
// which is surface this app has no use for and where the framework has
|
||||
// shipped critical advisories. Off, Next answers that route with 404
|
||||
// before it reads a single parameter. `next-image-optimizer-guard.test.ts`
|
||||
// holds this: it fails when `next/image` is imported while the optimizer
|
||||
// is off, and when the optimizer comes back with SVG input allowed.
|
||||
images: { unoptimized: true },
|
||||
// v1.4.38.4 — expose the package.json version to the client bundle
|
||||
// so the `<VersionPoller>` can compare the shell-baked version
|
||||
// against the live `/api/version` response and trigger a self-heal
|
||||
|
||||
+28
-25
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.44.0",
|
||||
"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.3.8",
|
||||
"nodemailer": "10.1.0",
|
||||
"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",
|
||||
"@next/bundle-analyzer": "^16.3.8",
|
||||
"@playwright/test": "^1.64.0",
|
||||
"@axe-core/playwright": "^4.14.0",
|
||||
"@next/bundle-analyzer": "16.4.2",
|
||||
"@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.3.8",
|
||||
"knip": "^6.41.0",
|
||||
"eslint-config-next": "16.4.2",
|
||||
"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)}`;
|
||||
@@ -113,6 +113,8 @@ export default defineConfig({
|
||||
testIgnore: [
|
||||
"v137-record-session-fence-offline.spec.ts",
|
||||
"pwa-offline.spec.ts",
|
||||
// Runs only in the WebKit phone project.
|
||||
"bottom-sheet.spec.ts",
|
||||
],
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
@@ -228,6 +230,8 @@ export default defineConfig({
|
||||
// Runs only in the service-worker project.
|
||||
"v137-record-session-fence-offline.spec.ts",
|
||||
"pwa-offline.spec.ts",
|
||||
// Runs only in the WebKit phone project.
|
||||
"bottom-sheet.spec.ts",
|
||||
// The Apple Health import journey uploads an archive into the one
|
||||
// shared account and its refusal control asserts that the account's
|
||||
// Apple-Health row count did not move. A second project running the
|
||||
@@ -304,6 +308,24 @@ export default defineConfig({
|
||||
colorScheme: "dark",
|
||||
},
|
||||
},
|
||||
// The bottom sheet's drag, on the engine it is for: iOS Safari is
|
||||
// WebKit, and Chromium's touch scrolling and pointer capture behave
|
||||
// differently enough that a pass there proves little about an iPhone.
|
||||
// CI runs it in its own job inside the Playwright image, where WebKit's
|
||||
// system libraries are already present; the Chromium shards set
|
||||
// E2E_SKIP_WEBKIT so they neither install WebKit nor pick the spec up.
|
||||
...(process.env.E2E_SKIP_WEBKIT
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name: "webkit-iphone",
|
||||
testMatch: ["bottom-sheet.spec.ts"],
|
||||
use: {
|
||||
...devices["iPhone 13"],
|
||||
colorScheme: "dark" as const,
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
|
||||
// Spin up the production build for E2E. `pnpm build` is run
|
||||
|
||||
Generated
+2094
-1146
File diff suppressed because it is too large.
Load diff
+15
-2
@@ -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);
|
||||
+18
-10
@@ -388,9 +388,10 @@ model User {
|
||||
// Shape: { "version": 1, "labs": { "view": "list", "order": ["<id>"], "sortDir": "manual" }, ... }
|
||||
moduleListLayoutJson Json? @map("module_list_layout_json")
|
||||
/// Per-user opt-in to preview features, one boolean per flag, all off by
|
||||
/// default (`src/lib/settings/preview-features.ts`). Read and written
|
||||
/// through `/api/settings/preview-features` and carried by the backup.
|
||||
/// @pending-consumer(topics preview) the `topics` flag is stored, but no surface reads it and the Settings > System switch is unlisted until the topic overview pages ship.
|
||||
/// default (`src/lib/settings/preview-features.ts`). Written by Settings >
|
||||
/// System > Preview features through `/api/settings/preview-features`,
|
||||
/// published on `/api/auth/me`, read by the navigation and the topic routes
|
||||
/// (`topics`), and carried by the backup.
|
||||
previewFeaturesJson Json? @map("preview_features_json")
|
||||
/// Optional activity goals the person set for themselves: workouts, active
|
||||
/// minutes and steps per week, steps per month. Null or an absent key = no
|
||||
@@ -4136,7 +4137,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 +4146,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)
|
||||
|
||||
|
||||
@@ -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
@@ -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.0";
|
||||
/* @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/*`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
@@ -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)`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Structural guard (#1267): a browser request that waits on a model call
|
||||
* takes its abort from the person's response-timeout setting, never from a
|
||||
* constant.
|
||||
*
|
||||
* The force regenerate aborted at a fixed 45 s while the server still had
|
||||
* minutes left on the person's 600 s setting, and a save of the about-me
|
||||
* self-context (which derives the clarifying questions inline) ran under the
|
||||
* 15 s request default. Both now read `clientAbortMs` through the account
|
||||
* payload's `ai.provider.responseTimeoutMs`; the inventory of every such
|
||||
* request is in the release notes for #1267.
|
||||
*
|
||||
* Its limit: it reads source text. A new synchronous AI route called from the
|
||||
* browser with a fixed `AbortSignal.timeout(...)` is not found by name here;
|
||||
* `use-insights-advisor-force.test.ts` proves the behaviour for the
|
||||
* regenerate with fake timers.
|
||||
*
|
||||
* Mutation check: put `FORCE_ADVISOR_TIMEOUT_MS = 45_000` back into the
|
||||
* advisor, or drop the `signal` from one about-me save, and this goes red.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
function stripComments(source: string): string {
|
||||
return source
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/(^|[^:])\/\/.*$/gm, "$1");
|
||||
}
|
||||
|
||||
/** The text of the call that starts at `open` (the index of its `(`). */
|
||||
function callText(source: string, open: number): string {
|
||||
let depth = 0;
|
||||
for (let i = open; i < source.length; i += 1) {
|
||||
if (source[i] === "(") depth += 1;
|
||||
else if (source[i] === ")") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return source.slice(open, i + 1);
|
||||
}
|
||||
}
|
||||
return source.slice(open);
|
||||
}
|
||||
|
||||
describe("browser aborts for model-backed requests", () => {
|
||||
it("the force regenerate derives its abort from the setting", () => {
|
||||
const source = stripComments(
|
||||
readFileSync(
|
||||
join(SRC, "components/insights/use-insights-advisor.ts"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
expect(source).not.toMatch(/FORCE_ADVISOR_TIMEOUT_MS/);
|
||||
expect(source).toMatch(
|
||||
/options\.force\s*\?\s*forceAdvisorTimeoutMs\(options\.responseTimeoutMs\)/,
|
||||
);
|
||||
expect(source).toMatch(
|
||||
/fetchAdvisor\(\{\s*force:\s*true,\s*responseTimeoutMs\s*\}\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("every about-me save carries the setting-derived signal", () => {
|
||||
const sites: string[] = [];
|
||||
const missing: string[] = [];
|
||||
for (const rel of walkSourceFiles(SRC, { floor: 3000 })) {
|
||||
if (rel.includes("__tests__/") || rel.startsWith("generated/")) continue;
|
||||
if (!rel.startsWith("components/") && !rel.startsWith("app/")) continue;
|
||||
const source = stripComments(readFileSync(join(SRC, rel), "utf8"));
|
||||
const pattern =
|
||||
/apiPut(?:<[^>]*>)?\s*\(\s*["']\/api\/coach\/about-me["']/g;
|
||||
for (const match of source.matchAll(pattern)) {
|
||||
const open = (match.index ?? 0) + match[0].indexOf("(");
|
||||
sites.push(rel);
|
||||
if (
|
||||
!/signal:\s*AbortSignal\.timeout\(saveTimeoutMs\)/.test(
|
||||
callText(source, open),
|
||||
)
|
||||
) {
|
||||
missing.push(rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Six saves today; a matcher that finds none must not read as clean.
|
||||
expect(sites.length).toBeGreaterThanOrEqual(6);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
]
|
||||
}
|
||||
Loaded 100 of 929 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user