Compare commits

..
Author SHA1 Message Date
Marc-André Bombeck 957c2f279e test(e2e): walk the Topics preview and sweep its pages at 390 px and with axe
A journey on its own account, seeded with 40 days of heart and sleep,
weight, blood pressure, a short run of steps and mood entries, and lab
markers with and without a trend: switched off there is no More tile
and no sidebar entry, the pages leave for Insights and the routes
answer 404; switched on in Settings the More sheet leads with the
Topics tile and the sidebar carries the entry below Insights; every
offered topic renders, with the pair that has 14 paired days stated
and the pairs short of it saying so.

The phone sweep opens every topic page with the preview on, and the
axe matrix scans the overview and two topics in both themes. The
Settings sweep gains the Preview features page.
2026-10-23 08:19:01 +02:00
Marc-André Bombeck c68e833dcc feat(topics): add the Topics preview area, its navigation entry and its switch
/topics shows a tile per topic the record holds data for, and names the
empty topics and the module-hidden ones once each. /topics/<topic>
shows the topic today, its signals side by side over 7, 30, 90 days or
a year against their usual range, how they move together, their trends,
its blocks, the single-metric pages behind it and what it is missing.
Every topic links into the metric pages, which stay as they are, and
each page ends on a neutral note that this is a preview.

Settings > System > Preview features is listed again and switches the
area on for the person reading. While it is on, the sidebar carries a
Topics entry with a Beta badge directly below Insights and the phone's
More sheet leads with a Topics tile; the bottom bar does not change.
The pages leave for Insights when the switch is off.

The charts load through the shared chart runtime. The sidebar rows move
into their own module and the More sheet tile into its own component,
so neither shell file grows; the lab trend wording is shared between the
marker page and the metabolism topic. Copy in all nine locales.
2026-10-23 08:18:53 +02:00
Marc-André Bombeck cc04e0944d feat(topics): compute topic overviews and topic pages on the server
GET /api/topics answers one entry per topic the record holds data for:
a headline value, a rule-based sentence and a 30-day sparkline.
GET /api/topics/{topic} answers a topic's signals with their usual
ranges, side-by-side series for a chosen window, the relationships
between its signals, 90-day trends, what it holds nothing for yet, and
its blocks (training load, lab markers with their trend, screener
trends, the cycle day).

Every figure comes from reads the metric pages already make: the local
daily values, the reconstructed nights, the mood score, computeLabTrend,
the training load and the screener trends. No model is involved. A
relationship says nothing below 14 paired days and is only called a
pattern at p < 0.05; a lab trend needs three readings spanning a year.

The preview is the viewer's own opt-in: with it off both routes answer
404 topics.preview_off. Topics, signals and blocks follow the record's
module switches and the sections the viewer's grant opens; the routes
read across sections, so a scoped grant is refused like the timeline.
/api/auth/me publishes the caller's opt-ins as previewFeatures.
2026-10-23 08:18:43 +02:00
Marc-André Bombeck 14d0184b63 fix(ui): give the neutral score pill a label colour that passes AA
The neutral tone set muted text on the secondary fill, which measures
3.6:1 in the dark theme. The secondary surface's own foreground reads
the same in light and passes in dark.
2026-10-23 08:18:33 +02:00
Marc-André Bombeck cf61bd1c85 Merge pull request #1264 from MBombeck/release/v1.45.0
Release v1.45.0
2026-10-23 03:26:55 +02:00
Marc-André Bombeck 8c43ff7cf9 fix(bench): move the heap snapshot and read the commit without a shell 2026-10-23 02:50:02 +02:00
Marc-André Bombeck 3b2ebc1081 docs(changelog): filters keep the address they were given 2026-10-23 02:06:12 +02:00
Marc-André Bombeck d8e4cf6000 fix(url): write page state into the query string through one serialised helper
Next 16.4 adopts a native history write a render later. A second write
inside that gap was undone when the router adopted the first, and a
write computed from useSearchParams started from the URL before the
last one. Clearing the vault's filters could therefore leave
?encounter= behind: the debounced search wrote the filters it had
rendered back over the clear.

@/lib/url-state is now the one way a page writes its own query: each
write starts from the live address bar, at most one waits for the
router at a time, and later updates queue on top of it and go together
once the router has it (or after a short grace). useUrlSearch reads the
same live value and reports each adoption. The filter hook (documents,
measurements, mood), the open document, the day layer, the timeline's
zoom and range, the medication tabs, the AI provider choice, the
Coach's ?c= and the one-shot parameter strips all go through it; the
filter changes merge into the live filters. A guard refuses raw
history writes and router writes built from the rendered params
outside the helper.

The timeline spec now waits for the values menu to hand focus back
before scrolling to the last row; it measured mid-animation and the
focus return scrolled the page back.
2026-10-23 02:04:21 +02:00
Marc-André Bombeck 333dfe8746 fix(backup): give every export read a total order
Several export reads sorted only by a column two rows can share (a
creation time, a day, a name), and a few to-many reads (medication
schedules, mood tag links, cycle and illness symptom links) had no order
at all. Tied rows then came back in whatever order they sat on disk, so
two exports of one account could differ, and the parity goldens flapped
once the fixture's child rows shared their pinned timestamps.

Each read now ends its order in the model's primary key: the existing
sort key plus `id`, or the remaining key columns of a compound key
(nutrient `source`, hidden tag `moodTagId`, link `symptomId` /
`moodTagId` / `documentId`). Schedules sort by window start, then id.
The restore re-export reads through the same modules, so it inherits
the order.

The parity goldens are unchanged: they already hold the tie-broken
order, so no regeneration was needed and they still pin the output of
the pre-split exporter.

A new integration test runs both purposes, built and streamed, through
a client that records every findMany and every nested to-many read, and
fails on any whose order does not cover the primary key (keys pinned by
an equality in `where`, or by the foreign key to the parent, count as
covered). A second case writes the same tied rows in forward and
reverse physical order and requires byte-identical files.
2026-10-23 00:28:38 +02:00
Marc-André Bombeck b09a6d2df8 test: fail a test whose code caught and logged a missing mock export
A module mock that leaves out an export the code under test reaches throws
at the call, and where that code catches and logs (a best-effort tail, a
fail-soft read) the test went on green while the path it meant to cover
never ran. The Apple Health import tests mocked the rollup module without
its fold window, so the import tail's rollup fold and cache invalidation
never ran in any of them; four route test files mocked the logging context
or the rollups without what the measurement tail reaches.

Both setup files now watch the console and fail the test in which such a
line was logged. The ECG import test keeps the real rollup module and
stubs only the fold, and the four unit mocks gain what they lacked.
2026-10-22 23:15:43 +02:00
Marc-André Bombeck 2844088251 test(integration): seed the large pulse account a month at a time
The heap test for the pulse day-mean rebuild wrote its million readings in
one INSERT ... SELECT, which outran the 60-second statement timeout on CI's
shared Postgres. The fixture is now written a month per statement, each in
its own transaction with the timeout lifted for that transaction only, and
ANALYZE the same way; the code under test keeps the timeout, and the
production-heap assertions are unchanged. With the timeout at 4 seconds the
old seed is cancelled with 57014 and the new one passes.
2026-10-22 22:56:05 +02:00
Marc-André Bombeck 294be53883 fix(admin): keep a sideways-scrolling table from widening the page on a phone
The login overview's IP button gained screen-reader text, which is
absolutely positioned. Inside a scroll container that is not positioned,
its containing block lies outside the container, so the span escaped the
container's clipping and every row panned past the edge widened the page:
the admin page scrolled sideways at 390 px. Every overflow-x-auto wrapper
around a table is now relative (the admin sections, the settings token
tables and the Coach result table), and a guard holds that.
2026-10-22 22:52:48 +02:00
Marc-André Bombeck dda4c57f5a docs(changelog): the maintenance job and today's mood dimensions 2026-10-22 22:05:24 +02:00
Marc-André Bombeck c10446f0d7 Merge branch 'release/v1.45.0' into fix/v145-review-data 2026-10-22 21:48:34 +02:00
Marc-André Bombeck a787cad7f9 chore(deps): take @tanstack/react-query 5.104.4, knip 6.42 and shadcn 4.22.1
The three updates the newest dependency pull requests carry, pinned the way each already was: exact for the query client, a caret floor for the two tools (knip ^6.42.0 resolves to 6.42.1). The dead-code gate is clean under the new knip.
2026-10-22 21:48:27 +02:00
Marc-André Bombeck 2ed863f877 fix(jobs): report the measurements bloat as whole percent, and keep finished runs finished
The nightly measurements check reported its bloat factor, a ratio such as
1.3, as a job fact. A fact is a non-negative whole number, so the outcome
validation refused it on every run, after the vacuum and the reindex had
already done their work, and the run failed. It now reports bloat_percent
(130), and the duration goes through the same rounding.

A malformed fact no longer fails a run whose work is done: runJob drops it
from the result and emits job.outcome.fact_invalid at error level with the
queue and the key, where failing would have made pg-boss retry finished
work. A failed outcome is unchanged. wholeFact and percentFact are the
intended way to report a measured value or a ratio, and a guard reads
every outcome producer and refuses a fact that is a division or names a
ratio unless it is rounded on purpose. The other producers were checked:
their numeric facts are counts and integer millisecond durations.

Refs #1271
2026-10-22 21:43:08 +02:00
Marc-André Bombeck 67135b2ce8 fix(charts): measure chart text in the app font again
Recharts measures every label in a span the chart runtime parks under
<html>, beside <body>, and the host copies body's classes to pick up the
app font. With next/font/google the font stack variable came from one of
those classes; since Inter is self-hosted, globals.css sets --font-sans on
body itself, so the host outside it measured in the default font and every
tick was sized for text the chart did not draw. The rule now sets the stack
on the measurement host too, and the dashboard's strings measure the same
inside the host as under <body> again.
2026-10-22 21:35:01 +02:00
Marc-André Bombeck 70c1077121 perf(mood): write a rollup fold in one statement per chunk
The full mood fold upserted every day bucket with a statement of its own,
so the read that refreshed the tier paid one statement per day of mood
history. The dashboard summary in the hot-route budget fixture did exactly
that: 57 to 59 single-row inserts out of its 85 to 87 statements, a count
that moved with the calendar the fixture is dated against, which is why it
crossed its budget of 84 on CI without a new query in the route. The fold
now writes each chunk of up to 500 buckets as one multi-row upsert, and the
route's budget goes to the measured 28.
2026-10-22 21:31:28 +02:00
Marc-André Bombeck bafda973ee refactor(insights): read the mood block's daily points in a module of their own
The rollup-or-fallback read of the per-day mood points moves out of features.ts into mood-daily-points.ts, unchanged, which keeps features.ts under its size baseline after the calendar-day ages.
2026-10-22 21:31:27 +02:00
Marc-André Bombeck 239319a762 fix(mood): count the mood block's ages in calendar days, and fold today in every zone
The Coach's mood block gave its newest and oldest entry ages as elapsed
hours rounded to days. A point from the rollup tier sits at its day
label's UTC midnight, so from noon UTC on this morning's entry read as a
day old; the integration run failed at 19:02 UTC for that reason. The ages
and the span are now whole days between the entries' own day labels and
the reader's today.

Pinning the clock to the edges of the Berlin day showed a second gap: the
open-ended rollup fold stopped at now, and a day label runs up to fourteen
hours ahead of UTC, so between a reader's local midnight and UTC midnight
the fold left their today out and the tier called yesterday the newest
day. The open-ended fold now reaches a day past now.
2026-10-22 21:23:19 +02:00
Marc-André Bombeck 5dab1488d7 fix(ai): let deferring dose sentences through and pair continuations with a named planned dose
The number-free dose shapes added for this release blocked harmless
replies: a trailing negation the pattern never reached ("Erhöhe dein
Insulin heute nicht", "Verhoog je insuline vandaag niet zelf"), a
Spanish subjunctive after "antes de que", a question ("Increase your
dose? Not without your doctor."), a clinician-gated condition, and
Polish "dose" of caffeine, sugar or exercise. These shapes now stand
down in a sentence that asks, negates, restricts ("nur wenn", "alleen
als") or names a clinician; shapes with a stated amount still block.
The Korean trial pattern stands down the same way, which also clears a
pharmacist question it already blocked.

The continuation exemption fell back to the whole plan when a sentence
named a drug the plan does not carry, so "Take Eliquis 5 mg as before."
and swapped doses passed. Every stated amount must now sit beside one
medication on the plan, named, at exactly that dose.

A false-positive corpus (at least 15 harmless replies per language)
sits beside the positive one. The Brazil crisis set's comment now says
what reaches it.
2026-10-22 21:19:01 +02:00
Marc-André Bombeck 8a9bcffaa2 build(deps): raise the dompurify floor to 3.4.17
GHSA-cg2g-xh64-v4q7 (critical, a SAFE_FOR_TEMPLATES bypass) and GHSA-mrv2-3pqr-j5jr (high, an IN_PLACE hook that removes a parent) cover dompurify up to 3.4.16, which jspdf and jspdf-autotable pull in. jspdf 4.2.1 is the newest release and still allows that range, so the existing override moves its floor. The doctor-report PDF renders as before (unit and integration suites).
2026-10-22 21:16:31 +02:00
Marc-André Bombeck bf12b9840c chore: drop an unused guard helper and a type re-export nothing imports
sourcesContaining in the guard helpers and the DeferredRows type re-export from full-backup-payload had no importer, which the dead-code gate reports. isDeferredRows stays re-exported; the backup job's test mocks it there.
2026-10-22 21:15:38 +02:00
Marc-André Bombeck 9da0e0c405 chore(release): v1.45.0 2026-10-22 20:51:30 +02:00
Marc-André Bombeck 32b71a7df9 Merge branch 'release/v1.45.0' into fix/v145-sheet-motion
# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
#	src/__tests__/baselines/source-function-lines.json
#	src/__tests__/baselines/version-stamp-comments.json
2026-10-22 20:51:07 +02:00
Marc-André Bombeck 6d23cca4a3 fix(day): keep focus where the person moved it when the day's heading mounts late
Opening the conversations beside a docked day dropped focus to the page
in most runs. A day opened on load asks for its heading to take focus;
when the heading had not mounted yet the request waited, and it was
honoured whenever the heading appeared, even after the person had moved
on and opened the conversations. Focus jumped into the folding day and
fell to the page as it went inert. A waiting request is now honoured
only while focus is still where it was asked from, never inside an inert
panel, and it ends with the day.

The timeline range check measured the popover during its slide-in and
read it up to 8 px too high; it now waits for the entry animation.

The label-in-name fixes move their name composition into small helpers
(score ring link, IP copy button, sortable header, period marker, the
day's title focus), so every function ends at or below its old length
and the size baselines only fall.
2026-10-22 20:50:07 +02:00
Marc-André Bombeck 562ff8a277 fix(a11y): name every control by the text it shows
axe 4.14 checks WCAG 2.5.3 (label in name), and the app frame failed it
on every page: the account link, the user menu and the day strip carried
an aria-label that left out their visible text. The same class sat in
the coverage meter, the score ring, medication links, instrument cards,
filter pills with a value set, cycle calendar days (named by an ISO
key), admin user and login rows, a few sort and action buttons, and the
French, Spanish, Italian and Polish weekday chips, whose abbreviations
were not part of the day names.

Controls with visible text are now named by it, with any extra purpose
added as screen-reader text; the select pill and calendar days spell
out a name that contains what they show. The day and conversation
strips keep their action as the tooltip.
2026-10-22 20:25:48 +02:00
Marc-André Bombeck 811fcf5509 fix(backup): restore row stamps and carry sealed labels, course notes and marker context as ciphertext
A disaster-recovery restore wrote these rows stamped with the restore's own
time: the medication's updatedAt (moved again by the course window), the
category assignment, custom category labels, courses and composition, the
mood day context and tag links, illness and cycle symptom links, nutrient
days, and a flare's updatedAt after the parent pointer pass. The recovery
file now carries each stamp (additive fields: linkCreatedAt on a mood
entry, symptomLinkCreatedAt on a cycle day log, createdAt on an illness
symptom, categoryUpdatedAt on a medication, updatedAt where missing) and
the restore writes it back. A file without them restores as before.

A custom category label, a course note and a marker's context rode the
recovery file as readable text and were sealed again under the active key.
They now ride as ciphertext (labelEncrypted, noteEncrypted,
contextEncrypted) and come back as the same bytes; a file that carries the
readable value instead is sealed on the way in, as before. The portable
file keeps the readable values. The rotation script and the
encrypted-column writer guard already cover all three columns.

The parity golden copies change for exactly this: the new fields in the
recovery file, the restored stamps in place of <now>, and the three values
coming back under the key they were sealed with. The legacy readable coach
title and custom metric note are still sealed on restore, since the row
holds no ciphertext to carry. The round-trip column audit no longer
excludes the four link stamps.
2026-10-22 19:57:47 +02:00
Marc-André Bombeck 038f8b10cd feat(mental-health): one check-in pattern for every questionnaire, worded as published
Every instrument now runs the same wizard: the form's own question stem
above each item (the PHQ-9 and GAD-7 stems were missing), the item's
exact answer options as one radio group in source order, progress, Back,
number-key answers, and a review step that lists every answer, lets any
of them be changed and is the only step that submits. The result leads
with the crisis card when it applies, then shows the score on the
instrument's scale, the band, what the band means, the change since the
last check-in against the meaningful-change threshold, and the next
step. Scoring is untouched.

The wording was read against the published versions. German follows the
PHQ-D and Löwe's GAD-7 verbatim, including the follow-up question in its
published formal address. Spanish, Italian and Polish carry the official
PHQ-9 and GAD-7 translations, French the official GAD-7, and the GAD-7
gets its own answer set because the Italian forms word it differently.
Where a version could not be matched to a published form (the French
PHQ-9, every Korean form) the language now shows the English original.
Each locale list names its sources, and spot checks pin the corrected
wording.
2026-10-22 19:28:29 +02:00
Marc-André Bombeck 6c4c097517 fix(workouts): start the windowed workout read after the plain reads beside it
The windowed reader is async, so a Promise.all that listed it first held a rejected promise of it whenever a later element threw while the array was built, and nobody handled that rejection. It now comes last in each of those arrays.
2026-10-22 19:15:58 +02:00
Marc-André Bombeck cec7ce2ad5 docs(api): regenerate the OpenAPI contract for the family-history backup fields
The backup payload schema the admin restore routes publish now declares notesEncrypted and deletedAt on a family-history entry, which the disaster-recovery file carries since the previous change.
2026-10-22 19:10:55 +02:00
Marc-André Bombeck 760f261a5f test(guards): count fixed-path reads through readSourceText in the source-read ratchet
The ratchet on tests that read a source file by path matched readFileSync
and readFile only, so a guard that called readSourceText with a fixed path,
directly or through a local read(rel) wrapper, read the file without the
anchors readSource demands and was never counted. The classifier now counts
both shapes, and leaves alone a readSource call with anchors and a sweep that
feeds the reader from a walk.

The twelve guards that read a fixed file that way now go through readSource
with an anchor each read asserts on, so the baseline stays where it was.
2026-10-22 19:08:15 +02:00
Marc-André Bombeck 298515d156 ci: fail the mutation floors step, and scale nightly property runs by a factor
The floors step piped scripts/mutation-floors.mjs into tee without
pipefail, so it took tee's exit status and a module under its floor could
never fail the run. It now runs under bash with pipefail, as do the other
steps that pipe into tee, and a guard reads every workflow and fails on a
tee pipe without it. The other piped steps were checked: each either
validates what the pipe produced or pipes a command that cannot fail.

The nightly runs set FC_NUM_RUNS, an absolute count that replaced every
property's own: the database backup round trip, which states twelve cases
for its cost, ran a hundred times against a timeout sized for twelve.
FC_RUNS_FACTOR now multiplies each property's count (five for the unit
suite, four for integration), propertyTimeout grows a test's timeout by
the same factor, and the backup round trip uses it.
2026-10-22 19:04:20 +02:00
Marc-André Bombeck 422adf2986 test(measurements): pin that a stats re-post with a new source version, device or sleep stage still writes
The unchanged-stats shortcut skips the write when every overwritten column matches the stored row. The kit test covered a new value and a new device; it now also re-posts a step total that only names a new HealthKit source version and a sleep row that only changes its stage, and checks each is written with its syncVersion bumped and the tail started.
2026-10-22 19:00:54 +02:00
Marc-André Bombeck 3b2c2c45f3 fix(backup): carry deleted family-history entries in the disaster-recovery file
Every record with a tombstone (allergies, labs, conditions, vaccinations,
life events, measurements and the rest) rides the disaster-recovery file
deleted rows included, so a restore gives back the account as it was. The
family history read only live entries for both files, so a restore lost
its deletions, and it carried the note as readable text that the restore
sealed again under the active key.

The disaster-recovery slice now carries every entry with its deletion and
the note as the ciphertext at rest, and the restore writes both back
verbatim; the portable file keeps the readable live entries. The parity
golden copies change for exactly that: the deleted entry, its note's key
and the shifted ids after it.
2026-10-22 18:59:07 +02:00
Marc-André Bombeck faedcbba93 test(backup): pin seeded timestamps, tombstones, self-references and key ids
The parity harness normalised any timestamp written during the run to
<now>, and the seeded rows' createdAt and updatedAt were themselves written
during the run, so a restore that dropped a row's createdAt still matched
its golden copy. Every run-time timestamp of the seeded account is now
pinned to a fixed historical value, and the seeded snapshot is asserted to
hold none, so a lost createdAt shows as <now> against a historical value.

The account also carries a tombstone of every model that keeps them, a
flare under its condition and a superseded profile fact, so the second
restore pass resolves real pointers. The fixture is sealed under the
legacy key and a second key is active from the export on; sealed values
render as the key id they name and the text they open to, so a value the
restore re-encrypts instead of carrying over differs.

The golden copies are written from the backup code as it stood before the
export and restore moved into per-domain modules (64fa65b46), and the
current code matches them.
2026-10-22 18:55:56 +02:00
Marc-André Bombeck 6ab1f2d8a2 docs(deploy): order a split deploy for the job-queue schema upgrade in v1.45
A web-only process opens its queue connection without migrating and refuses a job-queue schema older than its own, so in a web/worker split the new worker has to start, and upgrade the schema from 45 to 46, before the new web containers. The v1.45.0 notes say so, name the one additive migration, state that a single-container install needs no action, and point a forked compose file at the new stop grace period.
2026-10-22 18:44:44 +02:00
Marc-André Bombeck 7fda400a8c fix(i18n): serve English for an unreadable catalog, refuse to boot without it
The server reads messages/<locale>.json from disk on first use. A missing
or corrupt file threw, and since the matchers iterate every locale, one bad
non-English catalog turned every request that touched them into a 500.
That locale is now served the English catalog, and the failure is logged
once as i18n.catalog.fallback with the locale and the reason. English is
the fallback itself: a process whose messages/en.json is missing or does
not parse refuses to start, with the file and the reason in the message.

The image build checks the catalogs twice with
scripts/verify-message-catalogs.mjs: in the builder against the locale
list in the source, and in the runner stage against the copies the image
carries.
2026-10-22 18:43:03 +02:00
Marc-André Bombeck ac31130bc0 fix(cache): mark measurement caches stale after every bulk write
Closed analytics blocks survive a write that says it reached no further
back, so a writer that never invalidated left them on their pre-write
values. The Apple Health and Health Connect imports, the CSV and JSON
imports, the score engines, the WHOOP webhook, the Withings activity, sleep
and ECG legs, the Oura history backfill and the sleep-segment sweep wrote
measurements without touching the read caches; a long-history import
showed the old all-time figures until the entries aged out.

The shared post-mutation tail now marks the caches stale back to the
oldest row it is handed, and the Withings and Oura legs that hand-rolled
the same tail run through it. The two export imports share one tail that
refolds the rollups, marks the caches stale back to the oldest sample
written (all of them when none was) and re-warms the status assessments.
A sweep that tombstones a sleep segment drops every cached block.
2026-10-22 18:37:25 +02:00
Marc-André Bombeck 49a999ab94 test: tighten the version-stamp baseline and sweep instrument wording through the source helper
The wizard drops its slug alias to stay inside its function-size
baseline, the bare-t() sweep reads the tree through readSourceFolder,
and the version-stamp baseline takes the lower counts the dose-screen
and questionnaire changes left.
2026-10-22 18:31:05 +02:00
Marc-André Bombeck c15c1d18f3 fix(coach): read one lab marker's history in full
With an analyte, the Coach's labs block read the newest 4,000 lab rows of
every marker and matched the asked-for one in memory afterwards. An account
with that many newer readings of other markers lost the marker's older
readings, so its count and first date named a shorter history than the
record holds, or the marker vanished. The match now runs in SQL before the
row cap, on the biomarker's name for a linked row and the row's own name
otherwise, and the history compares units with sameLabUnit, so a reading
stored as "MG/DL" beside "mg/dL" stays in the series.
2026-10-22 18:22:36 +02:00
Marc-André Bombeck e78d5f86a6 fix(i18n): say what the Coach reads of older lab results when the lookback has no limit
The no-limit option promised every lab result, and the German detail
sentence ended on a dangling clause. The Coach keeps the latest value
of the most recently measured markers and, for a marker asked about,
its five previous results with older ones summarised per year for ten
years; years before that are only counted. Every language now says
that in complete sentences, with the two numbers pinned to the lab
history constants.
2026-10-22 18:21:58 +02:00
Marc-André Bombeck 466dfb3785 fix(workouts): resolve duplicate clusters the same way on every surface
The cross-source picker anchors each duplicate cluster on its first start,
so which cluster a workout lands in depends on the workouts around it. The
detail route still read the old fixed five-minute clock slot, and every
other reader handed the picker only the rows of its own range. The detail
of a twin that straddled a slot boundary declared itself canonical while
the list hid it, and runs at 09:58, 10:02 and 10:06 were grouped one way by
a read starting at 09:55 and another by a read starting at 10:00.

Every reader that decides which workout is canonical (list, detail and its
previous session, activity calendar, day view, doctor report, Coach,
briefing and correlation blocks, strain, per-sport context, the activity
insight's history, the batch write) now reads through one windowed reader.
It reads a duplicate window past the end of the range and reaches back
until every sport in the range starts at a workout that is an anchor
whatever came before it, then picks over everything read and keeps the
rows that start inside the range. The per-sport baseline and the insight
history leave out the whole cluster of the current session instead of the
row alone, which left its twin behind as a session of its own. The batch
write clusters its rows together with the stored workouts around them and
drops only a row that loses to another row of the same batch.

A property test checks the windowed read against a whole-history read for
random dense histories and ranges; route tests pin list, detail, deep link,
previous session and the batch write against the database.
2026-10-22 18:21:02 +02:00
Marc-André Bombeck 3354631a85 fix(ai): screen numberless halving and dose changes, and hold continuations to the plan
The outbound screen let a halving with no number through in every
language but Dutch ("Reduce your dose by half.", "Reduza a dose pela
metade.", "Tome meia dose."), let a bare change imperative through
("Zmniejsz dawkę.", "복용량을 늘리세요.") and did not know the Polish
spelling of the generic stems, so "Odstaw ramipryl." passed. Each
language's bank now carries a halving and fraction shape and a bare
change shape on a medication noun, and the stems include the Polish
-pryl, -statyn and -glutyd.

The continuation exemption trusted "as before" on any surface that
passed no schedule, so "Take 40 mg as before." went out to people who
had never taken 40 mg. The screen now takes the dose plan (each active
medication with its dose) instead of a bare list of numbers, and
exempts a stated amount only when it is the planned dose of the
medication the sentence names. Without a plan no stated amount is
exempt.
2026-10-22 18:20:02 +02:00
Marc-André Bombeck 47ac2c6889 fix(support): redact host names under Brazilian, Portuguese and other app-region TLDs
The support report's host-name step only knew a short list of top-level
domains, so saude.minhacasa.br:3000 or vida.example.com.br reached a
report meant for a public issue unredacted, as did hosts under .pt, .be,
.lu, .li, the Nordic codes and the English-speaking ccTLDs. The list now
covers the country codes of every app language's home regions, pinned
by a test against the shipped locales.
2026-10-22 18:09:22 +02:00
Marc-André Bombeck 38941c55d8 fix(mental-health): stop sending Portuguese readers in Portugal to Brazil-only crisis lines
The app ships one Portuguese bundle, and pt-PT, pt-AO and bare pt all
fold into pt-BR, so a stored pt-BR does not mean Brazil. A positive
PHQ-9 item 9 still answered it with SAMU 192 as the emergency number
and CVV 188 as the only line, neither of which connects from Portugal,
under a comment claiming pt-PT kept the international set.

With no region signal, Portuguese now gets the DE_AT_CH treatment: 112
first, the worldwide directory, then CVV and SAMU labelled Brazil and
SNS 24 (808 24 24 24, option 4) and SOS Voz Amiga labelled Portugal.
An explicit pt-PT tag gets the Portugal set and br the Brazil set.
2026-10-22 18:08:31 +02:00
Marc-André Bombeck 5448a43651 fix(mental-health): show unvalidated languages the English questionnaire wording
The PHQ-9, GAD-7 and WHO-5 are not validated in Dutch or Brazilian
Portuguese, and the SCI is validated in English only, yet the check-in
wizard and the answer breakdown read the item text, answer options and
stems through t() in the reader's language. Dutch and Brazilian
Portuguese readers saw unvalidated translations of every item,
including PHQ-9 item 9, under a note saying the questions were in
English.

Every surface now resolves instrument wording through instrumentText,
which returns the English source for a language outside the
instrument's validated set. The unvalidated wording is removed from
the bundles; the locale-parity check accepts exactly those keys as
absent and fails when a bundle carries one, and a structural test
keeps item text out of bare t() calls. The PHQ-9 follow-up question
regains its original wording. CONTRIBUTING states the rule for
translators.
2026-10-22 18:05:16 +02:00
Marc-André Bombeck 3decab47a0 fix(i18n): name dengue and tuberculosis in Brazilian Portuguese 2026-10-22 17:45:43 +02:00
Marc-André Bombeck d600d6f9d2 Merge pull request #1266 from asmala/feat/vaccines-dengue-tuberculosis 2026-10-22 17:45:16 +02:00
Marc-André Bombeck a3bf826fe7 Merge branch 'main' into feat/vaccines-dengue-tuberculosis 2026-10-22 17:27:17 +02:00
Marc-André Bombeck f38954f2ae test(e2e): read the reduced-motion sheet before its first frame
The check sampled getAnimations() once after the sheet showed, which passes or fails on whether WebKit drew a frame in between. It now reads the sheet and backdrop the moment they are inserted, the worst case, and is deterministic: it fails every time against the old classes. The other measurements in the spec already wait on polls, rendered frames or layout.
2026-10-22 17:02:27 +02:00
Marc-André Bombeck d6ed165ac6 fix(ui): open the bottom sheet without any slide under reduced motion
data-[state=open]:animate-in carries an attribute selector and outranked motion-reduce:animate-none, so the slide stayed, only cut to 0.01 ms by the global reduced-motion rule. It still existed and ran until the next drawn frame. The none is important now, on the sheet and the backdrop.
2026-10-22 17:02:26 +02:00
Marc-André Bombeck cdad3057cf test(guards): carry main's sizes into the ratchet baselines
Every raised entry is main's own number for a file this branch had not touched since the fork, so no value is above both sides; the version-stamp counts that fell are recorded lower.
2026-10-22 17:02:26 +02:00
Marc-André Bombeck 9c8972bc96 Merge origin/main (v1.44.2) into fix/v145-sheet-motion
next stays on main's 16.4.2 (the RCE and Server Actions advisories) with its two companions; release's newer dev tools and its own additions are kept. The lockfile is release's, re-resolved. pt-BR, which main does not have, takes the one key main added.
2026-10-22 16:43:28 +02:00
Marc-André Bombeck 8e7788132a Merge pull request #1270 from MBombeck/release/v1.44.2
Release v1.44.2
2026-10-22 16:07:20 +02:00
Marc-André Bombeck abb79ee6ae fix(ai): let the accented-word safety and grounding patterns match
JavaScript's \b is an ASCII word boundary, so next to an accented
letter it never matches. That left patterns, or alternatives of them,
dead: the French "en bonne santé", "à cause de", "causé" and
"continuez à", "probabilité" and "probabilità" in the French and
Italian risk banks, the Dutch "één procent", the German "Ärzte", and
the Polish "przedział", "średnia" and the verb endings in "-ą". A \w*
stem cannot run through "ł" either, so "doprowadziło do" missed its
causal pattern, and the Polish adult pattern was spelled "dorosli"
without the "ś".

Those boundaries now use lookarounds that count the Latin letters
with diacritics as word letters (WORD_START / WORD_END in the shared
screen module, the same class inline in the regex literals), the
Polish stems take accented letters, and "dorośli" is spelled both ways.

Two guards keep the class out: every pattern of every locale's screen
and population-norm bank has to match at least one realistic sentence,
so a dead pattern fails the suite, and no pattern may put \b next to
an accented letter. Both fail on the old patterns.
2026-10-22 15:57:08 +02:00
Marc-André Bombeck 9497b62b20 test(e2e): let the spring-back drag see a rendered frame before release
WebKit in the CI container can go over a second without rendering a frame while events and timers run on; the trace shows the More sheet still sliding in after its animation reported finished. The sheet paints once per frame, so a drag released inside such a stall was never drawn. The spring-back check now holds the finger for two rendered frames, and the settle helper waits for two frames too.
2026-10-22 15:35:42 +02:00
Marc-André Bombeck a0e97a4e54 fix(ui): attach the bottom sheet's touch listeners when the sheet mounts
The non-passive touchmove listeners on the sheet and its backdrop were bound in an effect that ran while the portaled sheet was still unmounted, and never ran again, so they were never attached. The mounted element is now state, and the listeners follow it.
2026-10-22 15:35:41 +02:00
Marc-André Bombeck b89cb6a29f test(guards): drop the raised size baselines and record the lower ones
Undoes the raise from the pt-BR tables commit. After the screen banks,
population-norm patterns, insights footers, settings labels and
Telegram words moved into their own modules, every file that grew is
below its old line count, outbound-screen.ts leaves the over-800 list
and buildDoseChangeClass the over-200 list.
2026-10-22 15:26:38 +02:00
Marc-André Bombeck dcb2598bb2 refactor(ai): keep each language's outbound-screen banks in its own module
outbound-screen.ts held every locale's dose, risk, causal, continuation
and change-stem patterns plus the shared units and slot builder, and
grew with each language. The banks now live in
outbound-screen/locales/<locale>.ts, one per language, each exporting
the same LocaleBank shape; the units, target-dose shape, object slot
and generic-name stems are in outbound-screen/shared.ts. The screen
composes them per locale and keeps the matching logic, the
continuation rule and the general rationale.

No pattern changed: compiling the old and the new module and comparing
every bank of every locale, with and without a medication-name list,
gives the same regular expressions (108 of 108 identical), and the
screen tests still block and pass the same sentences in every locale.
The screen is 457 lines instead of 1,597, and buildDoseChangeClass is
a four-line composition.
2026-10-22 15:26:37 +02:00
Marc-André Bombeck 996cc15b50 ci(e2e): run the Chromium shards in the Playwright image too
Installing Chromium's system packages from apt stalled a shard for 29
minutes, the same way WebKit's did. The shards now use the image the
WebKit job already runs in, whose tag follows the lockfile's
playwright-core, so no browser or system package is installed at run
time.
2026-10-22 15:22:03 +02:00
Marc-André Bombeck 8c11bb8385 refactor(coach): keep the population-norm banks in one module per locale
The reference-sentence exemption's patterns move out of
coach-prose-grounding.ts into population-norms/<locale>.ts, composed
into the same Record the check already reads. The patterns themselves
are unchanged. A test feeds every locale one reference sentence the
English bank cannot match, so a module that is not wired fails.
2026-10-22 15:21:10 +02:00
Marc-André Bombeck f987efd9f3 refactor(insights): move the insights reply-language footers into their own module
The per-locale footer table is prompt text, not prompt logic; it moves beside the generator, which drops back under its size baseline after pt-BR joined the table.
2026-10-22 15:19:33 +02:00
Marc-André Bombeck 50a6247177 refactor(settings): move the managed-record option lists and label keys into a data module
The Coach metric and cluster options and the label keys of the managed-record settings forms are data; they now sit beside the forms instead of inside the component file, which drops back under its size baseline after the locale picker started reading the locale list.
2026-10-22 15:18:58 +02:00
Marc-André Bombeck 945e998516 refactor(telegram): keep the bare help and greeting words in a per-locale table
The two word lists lived inside the webhook handler and had no entry
for Dutch or Korean. They move to their own module, keyed by locale so
the compiler names a language that has none, and gain "hulp", "hoi",
"goedemorgen", "도움말", "도움", "안녕" and "안녕하세요" beside the
Portuguese words. The bot still matches the union on the folded text.
2026-10-22 15:16:42 +02:00
Marc-André Bombeck 6c15a66cde feat(mental-health): show Brazil's crisis line to pt-BR readers
A Brazilian Portuguese reader who answered PHQ-9 item 9 saw the
international set: 112 and two directories. They now see CVV (Centro de
Valorização da Vida), 188 by phone and the chat at cvv.org.br, free and
answering around the clock, with SAMU's 192 as the emergency number.
Both were checked against cvv.org.br and the Ministry of Health's SAMU
page. A bare "pt" or "pt-PT" keeps the international set, because 188
does not connect from Portugal.

The CVV label is translated in every bundle, and a test pins that every
resource of every locale's set resolves its label in that locale.
2026-10-22 15:15:48 +02:00
Marc-André Bombeck 36a1485b91 style: format the database helper test 2026-10-22 15:08:14 +02:00
Marc-André Bombeck 2d16183b58 feat(i18n): let translators edit the catalogues in the browser with Fink
Add an inlang project so Fink opens the repository directly:
English as the source, the nine shipped locales, and the
message-format plugin pinned to 4.5.5 reading messages/{locale}.json.
That plugin keeps the nested keys and {placeholder} tokens as they
are; the i18next and next-intl plugins Fink also supports either
rewrite numeric-keyed objects as arrays or misread quotes around a
placeholder as ICU escapes.

Checked against these files with the inlang CLI and SDK: all nine
locales load with no errors and no findings, a load and save without
edits leaves every file byte-identical, and an edit changes exactly
the edited lines. inlang's working files beside settings.json ignore
themselves in git and are kept out of the format check.

CONTRIBUTING gains the browser workflow (fork, push, pull request),
what a reviewer checks, and that machine translation is a draft a
reader of the language goes through before the pull request; the
new-locale steps now name the real locale list. A test keeps the
project's locales equal to the shipped ones and the plugin pinned.
2026-10-22 15:03:16 +02:00
Marc-André Bombeck dc9beaefb7 fix(i18n): spell the Korean singular forms the way English does
Korean has no singular plural category, so the two ...One strings are
never chosen at run time, but they carried {count} where English writes
the number. They now read "1일 후" and "검사 수치 1개", which leaves no
placeholder that English lacks.
2026-10-22 15:02:15 +02:00
Marc-André Bombeck 1bc5df17bc test(guards): write back the lower source-read counts
Since the guards read the source tree through the shared index, 35 of
them no longer read a source file by path, and the realism ratchet
failed until the lower numbers were recorded.
2026-10-22 14:57:59 +02:00
Marc-André Bombeck 10bd2e5d94 test(guards): raise the size baselines for the pt-BR tables
The outbound screen, the prose-grounding check and the insights and
Coach prompt files keep one table per locale on purpose: the dual-bank
rule (the reader's bank plus English) reads in one place, and every
other locale lives there. A ninth locale adds its rows, mostly to the
dose, risk and causal banks of outbound-screen.ts and its
buildDoseChangeClass table (+134 and +40 lines). The managed-profile
settings section and the Telegram handler grow by an import and the
Portuguese bare words.
2026-10-22 14:57:33 +02:00
Marc-André Bombeck 51f7d38b3c test(i18n): give the per-locale copy guards their pt-BR vocabulary
The delete-confirmation, sleep-debt, mood-prognosis, MCP-token and
AI-consent guards match each locale against its own phrases, and the
sharing-boundary guard against the word for account; pt-BR now has
them. The locale-count floors read nine, and the managed-profile picker
test counts the locale list instead of a literal.
2026-10-22 14:57:32 +02:00
Marc-André Bombeck 71123e5fae test(i18n): hold every catalogue to the English placeholders
Key parity let 38 pt-BR strings through that renamed or dropped a
{placeholder}: a renamed one renders as a raw token, a dropped one
loses what the sentence was for. Every non-English bundle now has to
use exactly the placeholders of the English string, with one
allowance: a singular plural form may spell out or add {count}.
2026-10-22 14:57:09 +02:00
Marc-André Bombeck 5c2f91463c fix(i18n): state the glucose band edges in the five locales that left them out
Since the explainer started naming the fasting bands in the reader's
glucose unit, the English, Dutch and Korean text states where normal,
prediabetes and diabetes begin and that one high reading is not a
diagnosis. The German, French, Spanish, Italian and Polish text kept
the older sentence without the bands or the caveat. They now carry
both, with the same placeholders as English.
2026-10-22 14:57:00 +02:00
Marc-André Bombeck fed49f9b76 fix(i18n): translate the last English leftovers and a stray backslash in pt-BR
Two badge and plan strings still carried the English text, and the
Coach plans description escaped its quotes twice, so the reader saw
\"se… então…\" with literal backslashes.
2026-10-22 14:57:00 +02:00
Marc-André Bombeck 34ee0ea6e8 fix(i18n): separate the intake from the amount in the pt-BR medication wording
"Dose" stood for both the amount of a medication and the act of taking
it, so "Editar dose" could mean either, and the mechanical toma-to-dose
pass had turned two questions into "Com que frequência você dose?".
Where English says intake, the Brazilian strings now say "tomada"
(tomadas registradas, histórico de tomadas, editar tomada); "dose"
stays for the amount and where English itself says dose.
2026-10-22 14:50:52 +02:00
Marc-André Bombeck 4df28c5f80 feat(i18n): fill the pt-BR keys added since the translation was drafted
411 English keys had no Brazilian Portuguese string yet: the 135 that
were already missing when the translation was opened, and everything
v1.44 and v1.45 added since (the activity calendar, source provenance,
the lab and medication CSV import, the doctor-report workout and
medication-effect sections, the segmented medication-effect view, lab
trends and OCR outcomes, the mental-health trend lines, and the new
settings, integration and error strings). The bundle now has exactly
the English key set.

Terminology follows the existing catalogue: medicamento, exame,
treino, faixa de referência, chave de acesso, você.
2026-10-22 14:50:52 +02:00
Marc-André Bombeck 4e47c52aed fix(i18n): match the pt-BR wording and placeholders to the English source
38 strings used other placeholders than English: {when} for {date},
{provider} for {name}, a {unit} English dropped since, and the
integration-card, document-archive and Apple Health freshness lines lost
{since}, {list}, {relative} or {time} altogether, so the reader saw a
label where the sentence belonged. Most of those lines had been
translated from the key name ("Plano limitado", "Erros do provedor")
rather than from the English text; the whole of those sections is
translated from the source now, along with the Dawarich, glucose
reference, setup, re-confirmation and connector-token lines that had
the same problem, and the 19 strings whose English changed after the
translation was drafted.

The Telegram help documented "tomado <nome>", but the bot only accepts
"taken <name>"; it now shows the command the bot understands.
2026-10-22 14:50:28 +02:00
Marc-André Bombeck 3577da6966 fix(coach): narrow the pt-BR population-norm patterns
A sentence the grounding check reads as population guidance has its
numbers exempted from the evidence check. Two of the pt-BR patterns
matched sentences about the reader's own readings: "(entre|de) ...
geralmente" fired on "Sua pressão de 158/97 geralmente fica...", and a
singular "médico ... recomenda" with a 30-character gap fired on "Seu
médico recomenda manter a pressão em 158/97". Either let an invented
figure through unchecked.

Every pt-BR entry now needs a population subject or a reference noun:
plural guideline, doctor or specialist subjects next to their verb,
plural adults needing something, and "a/o <metric> normal" without a
possessive. Portuguese month names join the written-date pass so "21 de
julho" is not graded as a figure, and quilos, por cento and passos
join the unit words.

Negative cases prove the reader's-own sentences are checked again;
positive cases keep the reference sentences exempt.
2026-10-22 14:50:17 +02:00
Marc-André Bombeck 68b428e3f5 feat(ai): screen Brazilian Portuguese model output for dose, risk and causal claims
The outbound screen runs the reader's bank plus English. For a pt-BR
reader that meant English only, so "Pare de tomar a metformina",
"Dobre a dose de insulina" or "Seu risco é de cerca de 12%" passed every
surface unchanged.

Add the pt-BR banks the other locales carry: the dose-change class
(change, take, skip, stop, halve, double and one-more imperatives in
the você form, with the medication nouns, drug classes and stop words a
Brazilian reply uses), the target-dose patterns, the risk-figure and
risk-engine patterns, the causal phrases, and the continuation and
change-stem pair that keeps "continue tomando conforme prescrito"
exempt. UI joins the insulin units, and the count words and dosage
forms gain the Portuguese spellings.

Tests pin each class blocking in pt-BR and the restatements, negated
warnings, referrals, past changes and dietary advice passing.
2026-10-22 14:50:10 +02:00
Marc-André Bombeck 929d83b3b6 test(e2e): record the whole drag instead of one fixed-time sample 2026-10-22 14:50:01 +02:00
Marc-André Bombeck 764794496c feat(i18n): register Brazilian Portuguese as a locale
Add pt-BR to the locale list and everything that hangs off it: the
client loader, Intl and date-fns mapping, the reply-language footers of
the Coach and insights prompts (English body, pt-BR footer, the same
path Korean and Dutch take), the citation-coverage normative words, the
no-key fallback copy, the AUDIT-C standard-drink line, country labels,
period search, CSV yes/no cells and the Telegram bare-word aliases.

The locale enums in the extract, managed-profile and record-settings
schemas now derive from the locale list instead of repeating it, and
the five notification and login-alert routes share one isLocale guard
instead of hand-rolled chains, so a later locale is one edit there.
A language tag is matched exactly and case-insensitively before its
primary subtag, so pt-BR and pt-PT browsers land on the Portuguese
bundle rather than English, and AUDIT-C no longer cuts a region tag to
two letters.

PHQ-9, GAD-7 and WHO-5 are not marked validated for pt-BR: the wording
has not been read against the published Brazilian forms, so readers get
the English items plus the existing note.
2026-10-22 14:50:00 +02:00
Marc-André Bombeck 6cf69bbf1c test(insights): pin the clock in the two-service workout collapse case
The case built its sessions from the real clock: one copy a day before
now, the other 30 s later. The cross-source workout picker groups sessions
into fixed five-minute slots of the epoch, so whenever the suite ran in
the last 30 s of a slot the two copies landed in different slots and
counted twice. It had nothing to do with day boundaries or time zones: the
outcome followed the minute of the wall clock alone.

The case now pins Date to an instant well inside a slot. With the pin at
12:04:45Z or 23:59:45Z the case failed under UTC, Europe/Berlin and
Pacific/Kiritimati alike; at the committed 12:02:00Z it passes under all
three.

The production code reads no clock here (the block takes `now` from its
caller). What the flake exposed is the fixed slot grid itself: two copies
of one session that straddle a slot edge are not collapsed. That is a
change to the picker every workout surface shares, so it is left for a
change of its own.
2026-10-22 14:49:55 +02:00
Marc-André Bombeck 1619b2406a fix(i18n): reshape the Brazilian Portuguese catalogue onto the English key structure
The pt-BR bundle carried 420 top-level sections that duplicated the
nested settings and onboarding keys (1,835 strings, of which 538 differed
from the nested copy), wrapped thresholds, gettingStarted and trendHints
in one-element arrays, and kept 48 keys English no longer has.

Rebuild it in en.json's order and shape: unwrap the arrays, drop the
stray top-level duplicates in favour of the nested copies (which match
the terminology of the rest of the catalogue), and drop keys English
does not have. No wording changes.
2026-10-22 14:41:17 +02:00
Marc-André Bombeck 402daa2753 Merge pull request #1251 from dareoon/feat/i18n-pt-br 2026-10-22 14:19:17 +02:00
Marc-André Bombeck 7363bcd5d3 ci(e2e): trust the checkout in the WebKit container; measure the day button once it settles 2026-10-22 14:16:08 +02:00
Marc-André Bombeck e18cd086fd ci(e2e): run the WebKit spec in the Playwright image, keep the shards on Chromium
Installing WebKit's system packages in every shard stalled one shard for
29 minutes on apt and left another without time for its tests. The
bottom-sheet spec now runs in its own job inside Microsoft's Playwright
image, whose tag follows the playwright-core version the lockfile
resolves; the shards install Chromium only and skip the WebKit project.
The e2e gate answers for the shards and the WebKit job.
2026-10-22 13:44:10 +02:00
Marc-André Bombeck 98a083c86e test(guards): scan once in the threshold and timezone guards
Both guards timed out at five seconds under a loaded unit suite. The
timezone formatter guard grepped the whole of src, the generated client
included, once per scan; it now reads the tree once without the generated
client and keeps a file only when a plain substring says a pattern could
match. The threshold literal guard parsed every client file once per rule
and twice per run; each file is parsed once, each rule's hits are found
once, and a file is parsed for a rule only when it carries one of the
rule's markers. A sample that the markers would skip fails the matcher
case, and a planted literal still fails the allowlist case.

Measured alone: the timezone guard 5.5 s to 0.45 s (its floor case 1.9 s
to 2 ms), the threshold guard 4.6 s to 0.54 s (its non-empty case 1.1 s to
0 ms, reusing the hits it already found).
2026-10-22 13:02:59 +02:00
Marc-André Bombeck 8fc4941200 docs(changelog): local thinking models and the regenerate timeout
Refs #1267
2026-10-22 13:02:03 +02:00
Marc-André Bombeck 9e2a8229ec feat(settings): say that the daily briefing reasons at its own depth
The provider's reasoning select reaches neither the Coach nor the
background jobs: the daily briefing thinks at Medium at most, lower under
the operator's cap, and not at all on the Frugal usage level. A line under
the select now says so, in every locale. Behaviour is unchanged.

Refs #1267
2026-10-22 13:01:20 +02:00
Marc-André Bombeck 6d250d092d fix(insights): wait as long as the response timeout allows in the browser
The regenerate button aborted its inline POST after a fixed 45 s, so on a
slow local model every regenerate was cut while the server still had
minutes left on the person's setting. It now uses clientAbortMs over the
route's sequential model calls, never less per call than the briefing's
own 180 s default, because the published value cannot say whether the
person set it.

A save of the about-me self-context derives the clarifying questions
inline and ran under the 15 s request default, which reported a failed
save while the server went on to commit it. All six save sites now take
their abort from the same setting. Every other browser request that waits
on a model either has no client timeout or only queues the work.

Refs #1267
2026-10-22 13:01:20 +02:00
Marc-André Bombeck 221960af88 fix(ai): ask a thinking model again when it answered with reasoning alone
Ollama serves thinking models such as Gemma 4 on /v1/chat/completions with
the thought in a separate `reasoning` field. A long briefing can come back
with empty `content`: the thought used the whole token budget, or the
answer itself was parsed into `reasoning`. The background briefing always
asks to reason, so every briefing on such a model ended in the empty-reply
sentinel, which the chain logged as status 0 / cause "network" in the same
second the server logged 200, and the read path showed as a timeout.

The Local client and the OpenAI-compatible gateway now treat a reply with
reasoning and no answer as a lesson: the endpoint and model are remembered
in the reasoning dialect cache as answering only unreasoned, the call is
asked once more with reasoning_effort "none", and later calls ask that way
until the lesson expires. A test drives the real client, safeFetch and the
pinned dispatcher against a loopback server that answers the way Ollama
serves Gemma 4, and reproduces the reported log line without the fix.

Failed hops now carry what the call ran into: `failure` (timeout, aborted,
refused, reset, socket, parse, empty_response, reasoning_only, ...) and a
code-shaped `code` on ai.chain.link_failed, closed vocabulary only. The
cause only says "timeout" for a deadline, and the briefing failure marker
records the kind so the empty state suggests a longer timeout only when
time ran out.

Refs #1267
2026-10-22 13:01:10 +02:00
Marc-André Bombeck bb3ee6b855 fix(safe-fetch): leave the deadline to the caller's signal
The pinned dispatchers kept undici's own 300 s header and body timers. A
local model with the response timeout set above five minutes answers a
non-streamed completion only once it is done, so its headers could take
longer than 300 s and undici dropped a call the caller still allowed; the
failure read as a bare network error. Every safeFetch call carries its own
AbortSignal.timeout, so that signal is now the one deadline.

Refs #1267
2026-10-22 13:01:10 +02:00
Janne Asmala 8ea0ba5297 feat(vaccinations): Add dengue and tuberculosis to the catalogue
Dengue: Two doses three months apart and no booster. It's listed with the
travel vaccines.

Tuberculosis: One dose at birth and no booster. It's listed under childhood,
since that's where it appears on most vaccination cards.

Both cite the relevant WHO position paper. The names are translated in
all eight languages, and the OpenAPI spec is regenerated to include the
two new vaccines.
2026-10-22 18:58:15 +08:00
Marc-André Bombeck f08277e595 Merge pull request #1265 from MBombeck/fix/v1441-next-security
Release v1.44.1
2026-10-22 12:26:44 +02:00
Marc-André Bombeck ed04103eb6 chore(release): v1.44.2 2026-10-22 12:05:28 +02:00
Marc-André Bombeck 803d90c6c4 Merge branch 'fix/1261-own-targets' into release/v1.44.2 2026-10-22 12:04:29 +02:00
Marc-André Bombeck ac9693fac9 Merge branch 'feat/bottom-sheet' into release/v1.44.2 2026-10-22 12:04:28 +02:00
Marc-André Bombeck 03b3f6a3ba docs: record the history fixes and what 16.4.2 measured 2026-10-22 11:32:57 +02:00
Marc-André Bombeck aae7bce078 test(security): write the next/image import matchers as literals 2026-10-22 11:32:57 +02:00
Marc-André Bombeck 6cd8ffa526 fix(documents): follow the address bar for the open document
The sheet compared useSearchParams with the last ?doc= it had seen.
Next 16.4 applies its copy of a pushState after the write, and a Back
pressed in between leaves useSearchParams reading no document on both
sides, so the sheet saw no change and stayed open. The sheet now reads
the query from window.location through useSyncExternalStore, re-read on
popstate and on every render, and the selection and close paths build
their URLs from the same live value.
2026-10-22 11:32:57 +02:00
Marc-André Bombeck c496e390be fix(day): hold a step until the layer shows the previous one
A step writes ?day= with replaceState, past Next's router, which adopts
the write a render later. Since 16.4 that render can land after a second
quick step and write the first step's URL back, so [ then ] pressed in a
row ended a day short. A step that finds the layer still showing another
day than the URL now waits, and publishOpenDay makes it in one write as
soon as the layer catches up. Never past today still holds for the sum.
2026-10-22 11:32:56 +02:00
Marc-André Bombeck 08675a4f13 Merge branch 'release/v1.45.0' into feat/v145-issues 2026-10-22 11:00:45 +02:00
Marc-André Bombeck 7bbe64ad1a refactor(healthkit): fold the per-type arrival tally into the ledger module
The batch route counted arrivals inline and grew past its size baseline
with the new-sample rule. tallyHealthKitArrivals now owns the fold, the
route hands it each landed entry and hour with whether it brought a new
value, and the size baselines move down to what the route is now.

Refs #1259
2026-10-22 11:00:06 +02:00
Marc-André Bombeck 190a83c513 feat(coach): read lab results over the whole record
The Coach read lab results from the last twelve months only, even with
the lookback set to its widest option, so a slow change in LDL or PSA
across years stayed out of reach. Labs are sparse, so they are no
longer held to the one-year window that bounds the dense readings: they
reach back over the whole record, and a lookback limit the person set
still applies.

The snapshot block keeps the latest value per marker, now over that
wider window, at its old size: it shares the prompt's size cap and is
never shed. get_labs with an analyte adds each matching marker's
history, summarised so it stays bounded: the newest five earlier
readings in full and older ones as one mean / min / max / count per
year, ten years at most, older years counted. With a heavy fixture
(30 markers, quarterly for 20 years) the overview stays at 4.3k
characters and a named LDL read grows from 0.2k to 1.2k characters for
80 readings. The MCP get_labs read and the lab resource follow, since
they run the same tool.

The widest lookback option no longer reads "All": it names what it
reads, twelve months in detail and every lab result, in every locale.

Refs #1263
2026-10-22 10:50:56 +02:00
Marc-André Bombeck 6a2247e422 fix(healthkit): count a type as arrived only when the batch brought something new
A batch that carried a type with duplicates only moved its
lastReceivedAt, lastTrigger and lastBackgroundAt, so a type whose live
delivery had stalled kept reading as freshly arrived for as long as a
replay, a retry or an older client re-sent its old samples.

The arrival ledger now moves those stamps only for a type the batch
brought at least one new or changed row for. An unchanged stats:
re-post and an hour posted again with its stored value count as
duplicates here; the wire keeps answering updated for them. A
duplicates-only type stamps the new last_seen_duplicate_at column on
its existing row and nothing else. The intake bulk route's dose entry
follows the same rule through the shared recorder. lastSyncedAt and
healthKitLastSyncedAt are unchanged.

GET /api/integrations/healthkit serves the stamp additively as
metricFreshness[].lastSeenDuplicateAt, and the Apple Health card names a
re-send that came after the last arrival.

Refs #1259
2026-10-22 10:34:45 +02:00
Marc-André Bombeck 743a1e742f test(guards): stop the slowest guards repeating their own work
Each of these asked the same question several times or did the expensive
part before the cheap one: the threshold guard parsed every client file
once per rule and twice per run, the i18n reverse check rebuilt its scan
three times and compared every leaf with every literal key, the step-up
caller guard stripped comments and searched every route path in every
file per case, the plausibility guard ran its writer sweep three times,
and the cycle verdict guard parsed every client file to find out whether
it mentions a cycle. Each now works a result out once, and a file is
parsed or searched only when a substring says it can match. A planted
violation still fails the threshold, plausibility, cycle verdict and
backup plan guards.
2026-10-22 10:30:59 +02:00
Marc-André Bombeck 4dc3bd46bf test(guards): read the source tree once per worker through a shared index
walkSourceFiles now lists each indexed folder (src, e2e, prisma) once
per worker and filters it by prefix, readSource and readSourceFolder read
through the same memoised file cache, and readSourceText is the cached
read for guards that read files themselves. The guards project shares a
module graph per worker, so a file is read once however many guards ask
for it. sourcesContaining adds the substring pre-filter for sweeps that
look for a few markers. Every guard over 1.5 s now reads through it;
anything outside the indexed folders is read from disk as before.
2026-10-22 10:30:50 +02:00
Marc-André Bombeck ff49bbb5fc fix(insights): shade the SpO2 chart from the own target when one is set
The oxygen saturation page drew a fixed clinical band, so a person with
a lower target of their own saw their normal range shaded as a warning.
The page now reads the resolved range from /api/user/thresholds and
draws it; without a target the clinical bands stay as they were.

Refs #1261
2026-10-22 10:23:15 +02:00
Marc-André Bombeck 90106d88c8 fix(dashboard): shade blood pressure, pulse and body fat from the own target
The dashboard bands took only the weight target into account. Blood
pressure, resting pulse and body fat were resolved from age and sex
alone, so a target set on /targets left the chart zones and the tile
ranges on the standard band.

buildDashboardBands now reads the stored targets blob and routes every
override through getEffectiveRange, the same resolver the targets page
and the doctor report use. A metric without an override keeps exactly
the band it drew before. The snapshot's targetBands carry the resolved
values, so a native client reading them needs no change; the field
descriptions now say the own target wins.

Refs #1261
2026-10-22 10:23:14 +02:00
Marc-André Bombeck 72e6fd6620 test(e2e): drive the bottom sheet on WebKit with an iPhone profile
A webkit-iphone project runs one spec: drag, spring back, flick, scroll before drag, a slider sideways, the three closes, focus return, the page's scroll position and pull-to-refresh. CI installs WebKit beside Chromium.
2026-10-22 10:20:30 +02:00
Marc-André Bombeck 55211c0e69 fix(pwa): never arm pull-to-refresh while a sheet holds the page
The listeners sit on window, so a drag inside an open sheet on the dashboard or the insights page armed the refresh behind it. They stand down while the scroll lock marks the body.
2026-10-22 10:20:30 +02:00
Marc-André Bombeck 1cc1cda7aa feat(ui): make every bottom sheet draggable
One bottom sheet on top of the Radix dialog, so focus, Escape, the
backdrop, aria and the page scroll lock stay where they were. The sheet
follows a touch with a transform, the backdrop lightens with it, and a
release past a quarter of the height or a flick down closes it; anything
less springs back. Grip and header always drag; content only from its
very top, and sideways gestures never count.

Every `side="bottom"` sheet and ResponsiveSheet on a phone render through
it: the More sheet, the capture and form sheets, the Coach drawer on a
phone and the day. The day opens on the new preview step and pulls up to
90 dvh, its footer held on the bottom edge. Focus now returns to whatever
opened a sheet, not only to a Radix trigger. The command palette fills
the window and keeps no grip or drag.
2026-10-22 10:20:28 +02:00
Marc-André Bombeck 601d5ea447 test(guards): walk the tree once in the timezone formatter guard
Each of the guard's scans started its own grep over the whole of src,
the generated client included, five times per run, and the floor case
timed out at five seconds under a loaded unit suite. The tree is read
once now, without the generated client, and a file is kept only when a
plain substring says a pattern could match; each scan's result is
reused by the cases that ask about it. The file runs in 1.9 s instead of
6.5 s alone, and the floor case in 2 ms instead of 2.3 s.
2026-10-22 10:19:11 +02:00
Marc-André Bombeck 703c92c776 test(integration): take the clock and the probe budget out of two verdict tests
The grant expiry case gave the grant 400 ms and slept 500: on a loaded
runner accepting, signing in and switching took longer than the grant
lived, and the grant expired before it was accepted. The grant now runs
30 seconds and the case moves the clock the resolver reads to the
instant the grant names, so the row still does not change and nothing
sleeps.

The key check's start-up probe is bounded at ten seconds and two per
query. Under load a probe query ran past two seconds and the check
answered inconclusive where the data says mismatch. The verdict tests
now run the check with time to finish; the cases that test the bounds
pass their own budget or sampler and keep them.
2026-10-22 10:14:03 +02:00
Marc-André Bombeck 0cefac9d68 Merge branch 'release/v1.45.0' into feat/v145-backup
# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
#	src/__tests__/baselines/source-function-lines.json
#	src/__tests__/baselines/version-stamp-comments.json
2026-10-22 09:59:37 +02:00
Marc-André Bombeck 6dfb7eada6 Merge branch 'release/v1.45.0' into feat/v145-runtime 2026-10-22 09:41:54 +02:00
Marc-André Bombeck 3524d7c289 test(integration): lower the hot-route statement budgets to the measured counts
/api/auth/me 18 to 13, dashboard snapshot 103 to 99, summary 86 to 84,
the 365- and 3650-day pulse series 6 to 5, the day view 48 to 47,
insights health status 60 to 59, correlations 44 to 43 and a 500-row
batch ingest 31 to 28.
2026-10-22 09:34:42 +02:00
Marc-André Bombeck a451a114a2 docs(bench): record the boot profile and the runtime results
The profile section in docs/contributing/benchmarks.md says how to run
scripts/bench/boot-profile.ts, what the profile of the v1.44 runtime
found (the worker graph as one 753 ms block, a live heap of about
165 MB of which the query compiler holds 50 and the catalogs 33) and what
changed. scripts/bench/results/v1.45.0-*.md hold the before and after
numbers per area, from back-to-back runs on the same machine. The size
baselines take the lowered numbers.
2026-10-22 09:10:14 +02:00
Marc-André Bombeck 83661319d2 refactor(cache): keep the closed-block key helpers in a leaf module
Readers that tag a cell as a closed block no longer import the
invalidation module, which several tests replace wholesale. The cache
route test counts the slice's pre-fold cell apart from the route's two
cells, and the provenance test reads the shared pick's ORDER BY over its
grouped rows.
2026-10-22 09:09:44 +02:00
Marc-André Bombeck 4c86b68363 perf(import): skip the update of a re-imported row that is already stored
Re-importing an Apple Health export the account already holds sent one
UPDATE per row the insert skipped, about two million statements for a
large export, nearly all writing back what was stored. One read per chunk
now finds the rows whose stored values equal what the update would write;
they count as updated as before, without the statement. A key the chunk
carries twice is written in order as before. The natural-key rescue moves
into the same module.
2026-10-22 09:09:44 +02:00
Marc-André Bombeck f50b6b6637 perf(series): read a dense window's rows once for buckets and figures
A long-window read of a dense series sent two statements over the same
canonical rows, the day or hour buckets and the strip's figures, so a
ten-year pulse read picked the canonical source per day and scanned the
readings twice. Both aggregates now run in one statement over the window
materialised once, with the same SQL each had (dense-series-read.ts).

An integration test keeps the two old statements as the reference and
requires the same buckets and figures for pulse (two ranked sources),
glucose and oxygen saturation, day and hour grains, in UTC,
Europe/Berlin, Asia/Kolkata and America/New_York, and the same empty
answer for a type without readings.
2026-10-22 09:09:42 +02:00
Marc-André Bombeck 8f1f4dc691 build: keep the test harness out of the image and the font bindings out of the export list 2026-10-22 08:38:04 +02:00
Marc-André Bombeck 3a4dc16e1d test(guards): read each file and the schema once in the backup plan check
The two-ended checks ask every declared file about every model and each
of its relations, and read the schema and the files again for each
question. With the backup split into more files that took two seconds a
case and timed out on a loaded runner; the reads are memoised now.
2026-10-22 08:36:35 +02:00
Marc-André Bombeck 5902401ab8 chore(release): v1.44.1 2026-10-22 08:25:49 +02:00
Marc-André Bombeck 1fa33f2aee fix(security): turn the image optimizer off
Nothing renders through next/image, so /_next/image only offered a route
that fetches, decodes and re-encodes whatever local path a request names.
With images.unoptimized Next answers it with 404 before reading a
parameter. A guard fails when next/image is imported while the optimizer
is off, or when the optimizer returns with SVG input allowed.
2026-10-22 08:25:49 +02:00
Marc-André Bombeck 44c2973a64 fix(deps): update next to 16.4.2 and nodemailer to 10.1.7
Next 16.4.2 carries the fixes for GHSA-673q-mjm6-r5xr and
GHSA-2464-fhr4-w9xw (unauthenticated remote code execution) and
GHSA-p3m5-59mp-jv5r (denial of service through Server Actions).
eslint-config-next and @next/bundle-analyzer move with it, both pinned
exactly. nodemailer 10.1.7 fixes the address-parser advisories.

The 16.4.0 ignore in the Dependabot config is gone: the history journeys
it was guarding pass on 16.4.2 with the day-step fix, and the note says
what was measured.
2026-10-22 08:25:48 +02:00
Marc-André Bombeck 76af868064 fix(day): step the open day from the URL, not from the last rendered day
A step read the day the layer had last published, which reaches the
shortcut and the header arrows through useSearchParams, a render and an
effect. Next 16.4 lets that chain trail a replaceState by more than a key
repeat, so two quick presses of ] both stepped from the same day and one
was lost. stepDay now reads ?day= from window.location, which the step
itself writes, and refuses a step past today on that value too.
2026-10-22 08:25:47 +02:00
Marc-André Bombeck 4fc6b0e386 Merge branch 'release/v1.45.0' into feat/v145-backup 2026-10-22 08:12:22 +02:00
Marc-André Bombeck 6f20ae4a8d test(backup): hold each schema slice's own rules at their edges
The whole-payload tests meet a slice's refinements only by accident. Each
pattern, bound and two-ended free-text rule now has the value just inside
and just outside it, with the issue it raises: the day keys, a long day's
time away, the Coach's table and trail caps, a life event's title and a
symptom's label, a profile fact's readable value and validity. The backup
validation mutation score goes from 25.5 to 41.6 %; the floor follows.
2026-10-22 08:12:00 +02:00
Marc-André Bombeck 12749f6839 refactor(backup): give each domain its own slice of the payload schema
The element schemas move out of validations/backup.ts into one
<domain>-backup-schema.ts per domain, beside its export and restore, with
the shared primitives in backup-schema-kit.ts. validations/backup.ts now
composes the slices into backupPayloadSchema in the same key order, with
the same defaults and the checks that span sections, so the parsed
payload, every validation message and the OpenAPI contract are unchanged.

The lazy-schema guard now covers the slices too: a slice is reached only
through the payload schema or another slice, never from a domain's export
or restore. The external-id guard carries the payload's exemption to its
slices, and mutation testing scores the slices with the payload schema.
2026-10-22 08:02:14 +02:00
Marc-André Bombeck 5a9a6ce78b Merge branch 'release/v1.45.0' into feat/v145-deps 2026-10-22 07:54:27 +02:00
Marc-André Bombeck 322652a86e refactor(backup): build the export from per-domain slices
buildFullBackupPayload now asks every domain for its slice at once and
lays the slices out in the file's key order (backup-export-sections.ts),
merging the manifest where it first appears. The measurement, medication,
mood, instance-settings and nutrient readers and serialisers move out of
the payload builder into their domain modules; the records section keeps
its composition and manifest, while labs, illness, allergies, family
history, workouts and documents each export from their own module. The
measurement restore half moves to measurements-restore.ts so the export
graph does not pull in the restore's dependencies.

The file, its counts and their order are unchanged; the golden copies
still match. The measurement completeness guard now reads the domain
module and also checks that every carried column is written back.
2026-10-22 07:46:02 +02:00
Marc-André Bombeck 508e19f190 test(backup): fill the parity account by key order and match audit rows by copy
A table read without an order comes back in whatever order its last
updates left on disk, so the column fill could pick a different row of a
two-row table from run to run. The audit rows of a restore are now found
by the copy's id instead of by time, since the database clock and the
test's clock do not agree to the millisecond. The golden copies are
written again from the code before the restore and export moved.
2026-10-22 07:45:19 +02:00
Marc-André Bombeck 5199df56db test(jobs): restate the backfill lane's expiry behaviour for pg-boss 12.38
Up to 12.37 pg-boss retried a job whose handler outlived its expiry in
the same worker beside the run it had given up on, and the lane test
pinned that as an executable statement. 12.38 fails the job and fires
the signal as before, but the worker fetches nothing more until the
handler returns, and the retry starts after it. The test now pins that:
one run at a time, the retry starting after the overrun ends, and the
lane's handler importing once per delivery with never two at once. The
lane's lock stays, as the guard for a second worker or instance.
2026-10-22 07:33:01 +02:00
Marc-André Bombeck 5d3794e734 build(fonts): self-host Inter so the build fetches nothing
next/font/google downloads Inter from fonts.googleapis.com and
fonts.gstatic.com while the app builds, and when that request fails the
build fails with it, which is what broke the arm64 image build and
several CI jobs. The files it downloaded now live in src/app/fonts/inter
under the SIL Open Font License, and next/font/local declares them.

They are the same bytes: Inter 4.001, one variable file per script
subset (Cyrillic and its extension, Greek and its extension,
Vietnamese, Latin Extended, Latin), each used for weights 400 to 700.
The declarations rebuild the stylesheet Google answered, the twenty-eight
faces with Google's unicode ranges in Google's subset order, swap
display, and only the Latin file preloaded. The local loader emits one
unicode range per call, so each subset is its own call under the
family Inter.

The Arial fallback face and the font-sans stack move into globals.css
with the metric overrides next/font/google shipped for Inter; the local
loader would have computed slightly different ones from the file.

A production build with every outbound request sent to a dead proxy
completes, where the same build with next/font/google fails on the
fonts.googleapis.com request. The content security policy already
allowed fonts from self only. A guard refuses a next/font/google import
anywhere under src.
2026-10-22 07:31:16 +02:00
Marc-André Bombeck eda90eeb56 fix(strava): read a dead refresh token from Strava's own fault shape
Strava's token endpoint does not answer RFC 6749 errors. A refresh
token it no longer honours, after the athlete revoked the app or the
token was rotated away, is a 400 with the API's generic Fault:
{"message":"Bad Request","errors":[{"resource":"RefreshToken",
"field":"refresh_token","code":"invalid"}]}. The classifier looked for
invalid_grant, which never arrives, so the failure was booked
persistent and the connection never asked for a reconnect.

The token error now carries the fault reduced to its resource, field
and code (or an RFC error code, should one arrive), and the classifier
lifts a 400 naming RefreshToken to reauth_required, from the error and
from its message after a retry loses the prototype. Every other refusal
is classified by its status as before, including the faults that name
the Application.

Strava documents the Fault and Error model but not these values; the
body is the one several unrelated client libraries captured from live
traffic between 2023 and 2026.
2026-10-22 07:31:15 +02:00
Marc-André Bombeck a57fa8335a style(mcp): tighten the metric index note 2026-10-22 07:25:08 +02:00
Marc-André Bombeck f8a88d1361 perf(auth): read the operator settings once per request
An app boot's /api/auth/me read the AppSettings singleton six times, each
reader with its own few columns: the MFA policy, the Assistant switches,
the module availability, the provider presence, the AI usage bounds and
the reasoning controls. They now take their columns from one read,
memoised for the request and read afresh inside a background job like the
readers themselves. No answer changes.
2026-10-22 07:25:00 +02:00
Marc-André Bombeck e00943ff7f perf(day): read the record sections six at a time
The day view started about twenty-five section reads at once beside the
readings, scores and movement, more than the pool holds, so a
neighbouring request waited for a connection. The sections now run six at
a time and come back in the same order.
2026-10-22 07:25:00 +02:00
Marc-André Bombeck f5d6710670 perf(analytics): keep closed blocks of past readings cached across syncs
Every sync marked all of an account's analytics cells stale, so the slice
re-read the pre-fold remainder (every reading older than five years,
folded per source and day, the slice's largest statement on a long
history) and the day view re-read its past history blocks, although a
sync of today's readings cannot change either.

A cache key can now name the instant its value's readings end before
(closedBlockKeyPart). invalidateUserMeasurements takes touchedFrom, the
earliest measuredAt a write created, changed or removed, and leaves such
blocks in place when it is not earlier; without it every block goes as
before. The batch route passes it from the identities it wrote. The
pre-fold remainder moves into its own module and is cached as a closed
block; the day view's past history blocks carry their end, with a day of
margin for a night across midnight.

The batch route's two up-front reads move into batch-stored-rows.ts with
the once-per-batch key rule they share.
2026-10-22 07:24:59 +02:00
Marc-André Bombeck b6e9eb98f7 test(integration): size the all-time count case to its measured cost
The rollup parity case writes over half a million readings and folds them
into DAY buckets: about 35 s on a CI runner alone. Run beside two other heavy
files on the same Postgres it took 1.7 times as long and crossed the 60 s
default. Nothing in the case waits on a lock: its advisory locks are scoped
to the worker's own database, and the measurement with the database capped
at two CPUs (14.6 s alone, 25.3 s beside the sharing matrix and the restore
memory test) shows plain contention. The case gets a 150 s budget, twice the
contended estimate. HEALTHLOG_INTEGRATION_PG_CPUS caps the database
container so a CI runner's contention can be reproduced locally.
2026-10-22 07:18:57 +02:00
Marc-André Bombeck 7248347188 test(integration): release the held lock when the migration has met it
The 0412 lock-retry case let go of the lock on a 2.5 s timer. On a loaded
runner the migrate CLI took longer than that to start, so the first attempt
found the lock already free, applied cleanly and never produced the 55P03
the case is about. The lock is now released once the attempt is seen queued
behind it in pg_stat_activity and gone again, which is the moment its own
timeout fired, and the case fails outright if no attempt ever queued. It
holds with the CLI start delayed by four seconds.
2026-10-22 07:18:56 +02:00
Marc-André Bombeck d396945f44 fix(api): publish the backup upload
POST /api/admin/backups/upload stores a backup file as a new copy, the
copy whose id the published restore takes, and it refuses a file for
reasons an operator has to be able to look up: a key this server does
not hold (backup.key.missing, with the key ids), an owner account that
does not exist here, a newer schema, a copy too large to store
(backup.upload.too_large). It was exempted from the contract as console
plumbing while its two siblings were published for exactly that kind
of refusal.

It is registered with the three bodies it reads (raw JSON, raw gzip, a
small multipart form) and every status it answers; the summary is the
three identity fields plus one integer count per class, so a class the
backup format gains later does not break a decoder. The round-trip
test calls it through the contract now.
2026-10-22 07:08:02 +02:00
Marc-André Bombeck 579789ec38 fix(api): declare the 400 and 415 the single-value profile writes answer
The six /api/auth/me scalar writes (unit preference, glucose unit,
automatic document reading, local lab OCR, workout routes, the central
Codex switch) read their body through safeJson with a 1 KB cap, so each
answers 415 for a body that is not JSON by content type and 400 for one
that does not parse, beside the 413 they already declared. Their
descriptions said so and the responses did not. All three now come from
one block.

The modules and disable-coach route tests had been skipping the
contract check over a 400 and a 409 the registry already declares; the
skips are gone.
2026-10-22 07:07:46 +02:00
Marc-André Bombeck 5ca3994523 fix(api): declare the FHIR refusals as the OperationOutcomes they are
A /api/fhir/* handler answers its own refusals as FHIR: a bad search
parameter is a 400 OperationOutcome, the hourly fhir bucket a 429
OperationOutcome (issue code throttled), both served as
application/fhir+json, the same way the 404 on a read already was. The
contract declared no 400 and the generic JSON 429 for the 429, and no
403 for the doctorReport module gate, which every data route runs.

The searches now declare the 400, every data route the 403
module.disabled envelope and the FHIR 429 with the rate-limit headers
apiHandler attaches to it. The module gate keeps answering the JSON
envelope: it is the errorCode every client already branches on for a
disabled module, and it runs before the FHIR face does. The route tests
drop their skips and assert the 429's content type and Retry-After.
2026-10-22 07:07:34 +02:00
Marc-André Bombeck c272324c1e fix(api): declare the auth gate's 403 on every operation behind it
requireAuth answers 403 auth.scope.insufficient to a narrow Bearer token
on a route that names no scope it holds, and requireAdmin answers 403
auth.admin.required to a session that is not an administrator's. Both
run before any route body, and the contract only said so in the prose
of the shared 401, so a client generated from it had no typed branch
for either. The route kit accepted the scope refusal on any operation
that declared a 401 for the same reason.

The 403 is added to the assembled path table rather than to
stdResponses. That set is spread into most operations, about three
hundred of them carry a 403 of their own, and most spread the set after
it, so a shared 403 there would have overwritten every module-off and
sharing refusal in the document. withScopeRefusal adds the shared
response where an operation has none and prefixes an operation's own
403 with the gate's code, keeping the sharing sentence last. Operations
that take no credential, and the three surfaces that authenticate by a
credential of their own, are left as they were.

The kit no longer tolerates the refusal on an operation's behalf, and a
guard holds every operation behind the gate to declaring it.
2026-10-22 07:07:19 +02:00
Marc-André Bombeck 4f2af7c544 test(release): hold the image's Prisma CLI install to the app's versions
The guard pinned the npm-installed dotenv in the Dockerfile to a literal,
which is how that install came to trail the app tree by a patch without
anything noticing. It now reads the Prisma and dotenv versions from
package.json and asks the Dockerfile for the same ones, engines included.
2026-10-22 07:06:51 +02:00
Marc-André Bombeck 901cd884e8 perf(measurements): skip unchanged stats: re-posts in the batch route
The phone re-sends the same daily totals on every sync. Each went through
the reconciler, about ten round trips in its own transaction, to write the
same values back, and as an update it started the DAY refold, the rollup
jobs, PR detection and a cache stale for the account.

One read over the batch's stats: ids now finds the rows whose live stored
row already holds every value the update would write; those are reported
updated exactly as before, per entry and in the summary, and nothing is
written or started for them. A changed column, a tombstoned row, a key
that occurs twice in the batch or a value the plausibility gate refuses
still goes through the reconciler.
2026-10-22 07:01:04 +02:00
Marc-André Bombeck 850988c832 perf(analytics): group before the canonical-source pick, and run without JIT
The shared canonical-source CTE sorted every raw reading in its window by
a CASE expression no index can supply; on the benchmark account the pulse
pick over ten years sorted 260,000 rows and spilled to disk. Its order
depends only on (type, source), so the pick now groups the window to its
distinct (type, day, source) triples first and runs DISTINCT ON over
those. The picked source per day is the same by construction; an
integration test runs the old statement beside the new one over a fixture
with overlapping sources, sources off the ladder, tombstones, empty days
and readings either side of midnight, under default and reordered ladders,
for every reader shape, and requires identical rows.

Every app session now sets jit=off. Postgres compiled the larger folds to
machine code, which took 100 to 140 ms of a 220 ms statement and saved
nothing for statements that run once and are bound by reading rows. JIT
never changes a result.
2026-10-22 07:01:04 +02:00
Marc-André Bombeck b5f5a5a580 perf(i18n): build the every-locale match indexes on first use
Seven matchers built their index over every shipped catalog at module
load (the GLP-1 tag map, the nutrient and metric names of the MCP reads,
the SNOMED and LOINC lab names, the Coach's health words, the lab-name
pattern), so loading the worker graph read all eight catalogs at boot.
Each index is now built the first time it is asked for.

The boot profile records the change: the longest busy stretch of the
main thread at boot fell from 753 ms to 241 ms, the live heap after five
idle minutes from 164 to 152 MB, and the catalog chunk sources (two of
9.7 MB) are gone.
2026-10-22 07:01:03 +02:00
Marc-André Bombeck e7b31fe8e0 refactor(backup): run the restore as ordered steps from per-domain modules
restoreBackup becomes an orchestrator: the checks before the first delete
move to restore-preflight.ts, and the transaction runs the steps listed in
backup-domains.ts in dependency order, emptying the account first and then
rebuilding it domain by domain. The inline restores for measurements,
medications, mood, labs, illness, allergies, family history, workouts,
nutrient days, documents, the instance settings and the cleared
notification channels move into one module per domain, beside the
existing section modules. Cross-domain references travel through one
typed context instead of local variables.

The order of every write, the refusals and their audit rows, the skip
report, the cleared counts and the reported phases are unchanged; the
golden copies of the restored account still match. Guards that pinned
the restore by path now point at the domain files.
2026-10-22 06:59:54 +02:00
Marc-André Bombeck 96062d198a chore(deps): keep vite on the 8.1 line its advisories were fixed on
Re-resolving Vitest 5.0.5 had carried vite from 8.1.5 to 8.3.4, two
minors nobody asked for: 8.2 resolves the root through real paths,
which moves file ids in a symlinked worktree, and 8.3 warns on every
run about config-loader features the test configs use. 8.1.6 carries
the same five fixes as a backport, so the lockfile takes that and the
floor in the workspace file stays as it is.
2026-10-22 06:58:12 +02:00
Marc-André Bombeck 01c935261b chore(deps): take nodemailer 10.1.7 for the address parser advisories
GHSA-8c8m-p3m8-cjg8 and two sibling advisories against the address
parser are fixed in 10.1.4; 10.1.5 to 10.1.7 are linear-time and
encoding fixes on the same line. The email channel hands the parser a
recipient address each account sets for itself.
2026-10-22 06:53:16 +02:00
Marc-André Bombeck 73362dc0ae chore(deps): take the weekly production and development updates
Production: the AWS SDK S3 client and lib-storage 3.1152.0, Prisma
(client, adapter-pg, CLI) 7.10.1, TanStack Query 5.104.3 and Virtual
3.14.21, dotenv 18.0.8, jspdf-autotable 5.0.9, lucide-react 1.59.0,
nodemailer 10.1.2, pg-boss 12.38.0, React and React DOM 19.3.1 (still
exact) and undici 8.12.

React 19.3.1 and undici 8.12.2 are security releases. Prisma 7.10.1
stops a nested interactive transaction from leaking its id into a
later top-level one.

pg-boss moves its schema from 45 to 46; the worker applies the
migration on start and the index builds continue in the background.
Its list and delete helpers now page and batch, which touches only the
queue retirement at boot, and that is idempotent. The retention test
pins the installed version, so it moves to 12.38.0 after re-checking
every source line it asserts.

Development: @axe-core/playwright 4.14.0, Playwright 1.64.1, Vitest and
its coverage provider 5.0.5, shadcn 4.22.0. The Stryker runner patch
still applies; it touches the runner's own name join, and 5.0.5 changes
nothing in how Vitest builds a test's full name. axe-core 4.14 turns on
label-content-name-mismatch for the WCAG 2.1 A tag the a11y specs run.

The S3 client is held on the same release as lib-storage rather than
the newest one its range allows, so the two share one SDK core.

Vite gets a floor at 8.1.6 for GHSA-587h-7wmj-h5pm and four sibling
advisories against its dev server and bundled launch-editor. It arrives
only under Vitest and never reaches the image.

The image's separate Prisma CLI install moves with the app tree to
7.10.1, and its dotenv to 18.0.8, which it had trailed by a patch.
2026-10-22 06:53:04 +02:00
Marc-André Bombeck dcf49dd14e refactor(backup): let the account-settings domain own the User column verdicts
The SETTING verdicts are now the keys of the codec table, so a column
becomes a setting by getting a codec and nothing else has to name it. The
verdicts for every other User column move beside it into
account-settings-classes.ts, and the restore half into
account-settings-restore.ts. The backup plan keeps the model verdicts.

The classification guard now also refuses a column that has both a codec
and another verdict, since the composed table would quietly carry it as a
setting.
2026-10-22 06:50:58 +02:00
Marc-André Bombeck 66f8b18cf8 Merge branch 'release/v1.45.0' into feat/v145-backup 2026-10-22 06:42:29 +02:00
Marc-André Bombeck a859adb59c Merge branch 'release/v1.45.0' into feat/v145-runtime 2026-10-22 06:41:30 +02:00
Marc-André Bombeck a20cf17539 fix(jobs): let running jobs finish on stop, and give the worker four pg-boss sockets
Next's signal handler closed the server and called process.exit, which
won the race against boss.stop, so a running job was cut off and stayed
active until its expiry. The shutdown handler now holds process.exit until
the boss has stopped, at most 30 s plus a margin, and compose gives the
container 45 s to stop.

A process that runs the worker gets four pg-boss connections instead of
two for its pollers, cron, supervision and job writes; a web-only producer
keeps two. Both come out of the same per-process budget.
2026-10-22 06:41:17 +02:00
Marc-André Bombeck 27e83f1a83 feat(observability): guard the container's memory, not only the heap
The heap monitor's tick now also reads the cgroup working set (usage minus
reclaimable page cache) against the container limit. At 85 % it writes one
runtime.memory.pressure line with the work in flight, and the heavy lane
sends new heavy jobs back to the queue for a minute and asks a sliced
holder to give way, for at most fifteen minutes per episode. Heap lines
carry the working set and the limit. Outside a cgroup with a limit nothing
changes.
2026-10-22 06:41:17 +02:00
Marc-André Bombeck 2ccac728f4 perf(i18n): read server catalogs from disk, one locale at a time
The eight catalogs were static JSON imports, so every server bundle
carried the whole eight-locale chunk as a source string and parsed all of
them: 32 parsed copies and two 9.7 MB chunk sources in an idle process.
The server now reads messages/<locale>.json the first time a translator,
the SSR cache or a matcher asks for that locale, and keeps one parsed copy
per process on globalThis. allMessages keeps its shape; its entries load on
first read. The image copies messages/ next to the server.

foldLabel moves to a leaf module, so the client medication map no longer
imports the catalog index.
2026-10-22 06:41:16 +02:00
Marc-André Bombeck 7e932c4740 perf(boot): watch the loop from the first line and load the worker in steps
The event-loop monitor starts first in register(), so loading the
database client and the worker graph is inside its view, and a stall line
now carries the process uptime, the startup phase, the boot step running
at the time and the oldest work in flight. Each boot step is timed, and
the boot ends with one boot.steps line.

The five registrars and the lifecycle helpers are imported one at a time
after boss.start(), with a turn of the event loop in between, instead of
all at once when the worker module loads. The longest stall is then the
largest registrar graph rather than the sum of all five.
2026-10-22 06:41:16 +02:00
Marc-André Bombeck 63e8630fcb perf(bench): profile a production boot and the idle heap
scripts/bench/boot-profile.ts starts the production build with every role
under the production heap ceiling against the seeded benchmark database,
and records the time to a healthy probe and to a ready worker, every
event-loop stall the process logs, the heap lines, the full collections
from --trace-gc, the main thread's busy stretches during the boot with
their self time by source module (resolved through the chunk source maps),
and one heap snapshot after an idle wait, summarised by node type,
constructor, largest strings and largest retained subtrees.

The baseline on the current runtime: a 753 ms busy stretch at 0.7 s, most
of it the worker graph, and a live heap of 164 to 172 MB after five idle
minutes, of which the query compiler holds about 50 MB and the eight
locale catalogs about 33 MB (32 parsed copies and two 9.7 MB chunk
sources).
2026-10-22 06:41:15 +02:00
Marc-André Bombeck 64fa65b460 test(backup): pin the file and the restored account against golden copies
Seed one account as far as the schema allows (a row of every two-ended
model, every column of those rows and of the account row, the instance
settings, a sharing grant), export it both ways and restore each file
into a fresh account, over the live account, and from a version-1 file
in the old shapes. The files, counts, parsed payload, preview, restore
outcome, reported phases, audit rows, wide-event fields and every row the
account holds afterwards are compared with golden copies after ids,
ciphertext and run-time timestamps are normalised. Refusals, skip reports
and the schema's issue list for malformed files are pinned the same way.

The seed moves out of the round-trip test into a shared fixture module
so both files fill the same account.
2026-10-22 06:22:30 +02:00
Marc-André Bombeck 0f6d668df1 fix(workouts): match cross-source duplicates by a start window, not a clock slot
Two copies of one session were collapsed only when both starts fell in the
same fixed five-minute clock slot, so a pair thirty seconds apart that
straddled a slot boundary (08:04:50 and 08:05:20) stayed as two workouts in
the list, the batch write, the calendar, strain and the Coach.

Workouts of one user and sport are now clustered by start time: a workout
joins the open cluster while it starts less than five minutes after the
cluster's first start. Wherever no pair within five minutes straddles a slot
boundary the clusters are exactly the old slots, which a parity test checks
against the previous picker on thousands of generated inputs, strain impulses
included. A property test shows any offset under five minutes now collapses,
five minutes or more never does, and a uniform shift of every start keeps the
picked set.
2026-10-22 06:16:54 +02:00
Marc-André Bombeck d98920415e docs(contributing): how the test suites are organised and written
docs/contributing/testing.md says which suite a test belongs in and why,
how to write a route test with the kit and what its contract check proves,
how the settings round trip and the integration database work, how the
shards are balanced and the timings regenerated, when to write a property
test, how the mutation floors are kept, and the flaky-test policy with the
quarantine format. Linked from the docs index.
2026-10-22 06:09:43 +02:00
Marc-André Bombeck f40cdd3dc3 test(guards): lower the realism and version-stamp baselines
The mocked route tests moved onto the kit take the ratchets down: route
tests mocking @/lib/db wholesale 321 to 274, route tests stubbing
apiHandler 138 to 133, as never casts in tests 6 014 to 5 496, and version
stamp comments 10 634 to 10 592 with the deleted files.
2026-10-22 06:09:11 +02:00
Marc-André Bombeck ae2636c9e5 Merge branch 'release/v1.45.0' into feat/v145-testkit
# Conflicts:
#	src/app/api/tokens/__tests__/route.test.ts
2026-10-22 06:08:47 +02:00
Marc-André Bombeck f5d4dd2ec6 test: mutation testing on the core libraries with per-module floors
Stryker runs against encryption at rest, medication compliance, the rollups,
source ranking, unit conversion and backup validation, with the unit tests
that live next to them (vitest.mutation.config.mts). It is too slow for a
pull request and runs nightly and on demand (.github/workflows/mutation.yml).
scripts/mutation-floors.mjs reads the JSON report and fails when a module
scores below its floor in stryker.floors.json, so one well-tested module
cannot hide a weak one. The floors sit two points below the first measured
scores: crypto 46.9 %, compliance 66.8 %, rollups 41.7 %, source ranking
57.0 %, units 87.4 %, backup validation 25.5 %.

@stryker-mutator/vitest-runner 10.0.0 predates Vitest 5, which names a test
"describe > test" where the runner still built "describe test". Every test
inside a describe block was filtered out of the mutant runs, so nearly every
mutant survived (5.5 % across the set). A pnpm patch builds the name the way
Vitest 5 does; the Docker dependency stage copies the patches directory,
which the frozen install needs.
2026-10-22 06:08:20 +02:00
Marc-André Bombeck 1915f91526 test(insights): pin the clock in the two-service workout collapse case
The case built its sessions from the real clock: one copy a day before
now, the other 30 s later. The cross-source workout picker groups sessions
into fixed five-minute slots of the epoch, so whenever the suite ran in
the last 30 s of a slot the two copies landed in different slots and
counted twice. It had nothing to do with day boundaries or time zones: the
outcome followed the minute of the wall clock alone.

The case now pins Date to an instant well inside a slot. With the pin at
12:04:45Z or 23:59:45Z the case failed under UTC, Europe/Berlin and
Pacific/Kiritimati alike; at the committed 12:02:00Z it passes under all
three.

The production code reads no clock here (the block takes `now` from its
caller). What the flake exposed is the fixed slot grid itself: two copies
of one session that straddle a slot edge are not collapsed. That is a
change to the picker every workout surface shares, so it is left for a
change of its own.
2026-10-22 05:58:45 +02:00
Marc-André Bombeck 9bdd0b1a60 perf(bench): record the baseline on the v1.44.0 runtime
One run of scripts/bench/run.ts against the seeded account (1,266,549
measurement rows, anchor 2026-10-22), 20 measured requests per row after 3
warm-up requests, web-only production build, 792 MB heap ceiling, Postgres
16.15. The commit adds only lint rules, guards, tests and scripts on top of
v1.44.0, so the server code measured is the v1.44.0 code.

Cold (caches evicted before each request), p95 and statements per request:
/api/auth/me 17 ms / 19, dashboard summary 233 ms / 31, dashboard snapshot
2253 ms / 158, pulse series 30 d 59 ms / 8, 365 d 311 ms / 8, 3650 d
2118 ms / 8, day view 78 ms / 49, insights health status 140 ms / 68,
correlations 105 ms / 45. Warm, the snapshot is 24 ms / 13 and the 3650-day
series still 1506 ms, since that read is not cached. The 5,000-row batch
ingest took 1722 ms and 424 statements over ten requests.

The machine was shared with other work during the run (load average 8 to
20), so latency is indicative; the statement counts do not depend on it.
The results directory is written by the script and left out of prettier,
so a re-run diffs cleanly.
2026-10-22 05:55:38 +02:00
Marc-André Bombeck d539eee90b test(integration): expect the worker's own database in the environment check
The environment check pinned the database a worker talks to as the single
shared one. Each worker now runs against its own clone, so the check names
the clone and asserts it is not the migrated template the clones copy.
2026-10-22 05:48:05 +02:00
Marc-André Bombeck d010ed2301 ci: a nightly run that shuffles both suites and tries new property inputs
Once a night both suites run with files and tests in a random order, seeded
and printed, so order dependence surfaces on purpose rather than on somebody's
pull request; a failure replays with --sequence.seed. The same run gives every
fast-check property a fresh seed and five times the cases. The integration
reports are merged into a regenerated timing file and uploaded for a
maintainer to commit when the shards drift apart. Not a gate.
2026-10-22 05:39:55 +02:00
Marc-André Bombeck e9f89bbebf ci: shard both suites by measured duration and split the unit suite
Vitest's --shard cut the file list into slices of equal length, which gave
the integration shards 954 s to 1 449 s. A sequencer now deals files out by
their measured duration, plus a fixed per-file cost for import and setup,
longest first to the shard with the least work, and starts the longest files
first inside a shard. Timings live in tests/durations/*.json and are
regenerated from Vitest JSON reports with scripts/test-durations.mjs; every
CI shard uploads its report.

Integration shards run three workers each, one cloned database per worker.
The unit suite runs as three projects decided per file from its source:
guards and unit share a module graph inside a worker, isolated keeps a fresh
one for every file that mocks, spies, stubs or resets. In CI it is three
duration-balanced shards next to a job for lint, format, types and the
OpenAPI drift check.

The required check names stay as they were. "Lint, Typecheck & Test" is now
the job that answers for the static checks and every unit shard, the way
"integration" already answers for its shards. The browser-install guard
recognises a direct, flags-only vitest run as a run of the unit suite.
2026-10-22 05:39:35 +02:00
Marc-André Bombeck d640eea32a test(integration): hold statement budgets on the hot routes
tests/integration/query-counter.ts counts the statements the app sends
while a function runs, by wrapping Client.prototype.query on the copy of pg
the Prisma adapter itself resolves (so a second pg in node_modules cannot
leave it reading zero; a window with no statement throws). Background tasks
are settled before the window closes, so fire-and-forget writes count with
the request that started them.

query-budget-hot-routes.test.ts seeds a small account (four months of the
main types with overlapping sources, stats rows, sleep, glucose, a
medication, workouts and mood), warms each route once, evicts the user's
caches and counts the next call. Budgets, measured on this fixture:
/api/auth/me 18, dashboard summary 86, dashboard snapshot 103, pulse series
30/365/3650 days 6 each, day view 48, insights health status 60,
correlations 44, a 500-row batch ingest 31. A failure prints every
statement grouped by text.

Proof: one extra `SELECT 1` before every handler in apiHandler failed all
ten budgets by exactly one (18 → 19, 86 → 87, … 31 → 32), which also shows
the counts are deterministic; with the probe removed all ten pass.
2026-10-22 05:38:45 +02:00
Marc-André Bombeck 375ca12b05 perf(bench): add a large-account seed and a request benchmark
scripts/bench/seed-large-account.ts (moved from scripts/, extended) writes
one synthetic account of ten years: 1,265,561 measurement rows across the
main types and five sources (Apple Health dense spot rows, Withings blood
pressure and scale, a weekly manual weight, three years of Oura nightly
values, 90 days of Nightscout CGM), `stats:` and per-sample cumulative rows,
nightly sleep stages, 3,020 workouts, 1,095 mood entries and three
medications with 3,285 intake events. Everything goes in through set-based
INSERT … SELECT generate_series; values and timestamps are functions of the
row index and `--anchor`, so a run is reproducible. The rollup tier is
folded with recomputeUserRollups, as the boot backfill does, and the tables
are analysed. About 80 s on the Mac mini.

scripts/bench/run.ts boots the production build once with every role (so
the queues exist), then measures against a web-only process under the
production heap ceiling: p50, p95 and max latency and SQL statements per
request (pg_stat_statements, read once nothing is in flight and the count
holds still) for /api/auth/me, the dashboard summary and snapshot, the pulse
series over 30, 365 and 3650 days, the day view, insights health status and
correlations, each cold (caches evicted by a one-row manual-sync ingest
before every request) and warm, plus a 5,000-row batch ingest as ten
requests of 500. It writes JSON and a markdown table.

docs/contributing/benchmarks.md describes the setup against a throwaway
Postgres in Docker with the production pg_stat_statements settings.
2026-10-22 05:38:36 +02:00
Marc-André Bombeck 28f9b1948b test: a flaky-test quarantine with an expiry the guard enforces
tests/quarantine/quarantine.json is the only way to take a flaky unit or
integration test out of a run. An entry names the file, the full test name,
the issue, the reason, the day it was added and the day it expires, at most
14 days later. Both setup files skip a listed test while its entry is live,
reported as skipped with its issue rather than retried into green. Past its
expiry date the test runs again, and quarantine-guard.test.ts fails until the
entry is removed; it also fails on an entry naming a file or test that does
not exist. The list starts empty.
2026-10-22 05:33:09 +02:00
Marc-André Bombeck 4f5adb4fae fix(units): show zero rather than negative zero after a unit conversion
A converted value that rounds to zero from below came out as -0, and
Intl.NumberFormat prints that as "-0": 0 °F typed and shown back read
"-0 °F", and a body-temperature deviation of a few hundredths below zero read
"-0.0 °F". The rounding now returns plain zero. Found by the new unit
round-trip properties, which check that every value a person can type in
their own unit is shown back exactly as typed.
2026-10-22 05:32:49 +02:00
Marc-André Bombeck 4ff3f3eb5d test: property tests for zones, statistics, cadence, crypto and backups
fast-check properties with one shared configuration (tests/property/): a
fixed seed on every pull request, a fresh one and more cases at night through
FC_SEED and FC_NUM_RUNS, and a printed seed with the shrunk counterexample on
failure.

- Time zones: wall clocks agree with Intl in twenty awkward zones, a wall
  clock maps back to an instant showing it, a skipped hour resolves forward
  on the same day, every instant lies inside the window of the day it is
  filed under, and day keys shift and count as inverses.
- Statistics: every median in the tree says the same number, medians and
  spreads obey order, shift and scale, a least-squares fit recovers an exact
  line and composes from stored sums exactly, and the segmented regression
  recovers a noiseless interrupted line.
- Cadence and compliance: weekly and daily schedules expect exactly what a
  day-by-day oracle expects, in any zone and on transition days, and the
  rate stays within bounds and never falls when a missed dose is taken.
- Encryption: every codec opens what it sealed under the key it names,
  rotation moves the key id and nothing else, and a tampered value never
  opens to anything but the sealed bytes.
- Backups: randomly generated accounts come back from a disaster-recovery
  restore with an identical canonical dump of every generated table.
2026-10-22 05:32:48 +02:00
Marc-André Bombeck 8b89b65f25 build(deps): add fast-check and Stryker as development dependencies
fast-check 4.10.2 for property tests; @stryker-mutator/core and the Vitest
runner 10.0.0 for mutation testing of the core libraries. Exact versions,
each published more than two weeks ago; the audit reports nothing new.
2026-10-22 05:32:37 +02:00
Marc-André Bombeck bc3522aabb test(integration): move medication sub-route tests onto Postgres
Phase config, cadence, side effects, inventory, intake purge, bulk delete and
the import status run through the real handlers against the database. The
advisory-lock ordering the mocked tests read off call order is shown with a
real concurrent transaction holding the inventory lock, cache eviction through
the next list read with a control that the cached list would otherwise stay
stale, and soft deletes on the tombstone and sync counter they leave.
2026-10-22 05:32:22 +02:00
Marc-André Bombeck 43b86e46da test(integration): move notification channel and workout route tests onto Postgres
The channel settings routes, their test-message routes, the reminder
thresholds and the workout detail, delete and batch-scope tests run through
the real handlers against the database. Only the far ends are faked (the
relay, the ntfy server, the Telegram Bot API, SMTP), and the tests assert what
those received. The private-origin source guard stays a unit test and now
points at the two files that carry its runtime proof.
2026-10-22 05:32:22 +02:00
Marc-André Bombeck ff9ef0883b test(integration): move the mood tag route tests onto Postgres
The effective tag read, hide and show, custom tags and groups, and the tag
layout are tested through the real handlers on stored rows instead of a
mocked Prisma client: the migration-seeded catalogue served after a truncate,
owner scoping that leaves another account's tag byte-identical, the caps,
sealed labels, soft delete against purge, a deleted group re-homing its tags,
and the layout's optimistic token from 200 through 409 to a successful retry.
2026-10-22 05:32:21 +02:00
Marc-André Bombeck 26df61f638 test(integration): drive the Strava sync against Postgres
The Strava writer had eight mocked test files and no test against a
database. strava-sync.test.ts runs the real sync behind a fake Strava API at
the safeFetch boundary: field mapping, the after= watermark, idempotent
re-sync on the (user, source, external id) key, in-place overwrites that keep
what only the detail read carries, a cursor that never moves backwards or
past a row that failed to write, the paged backfill with its resume point
and requeue, token refresh with both rotated tokens stored sealed, and the
integration ledger after transient and permanent failures. The sync, the
disconnect and the credential routes are covered through the kit.

The mocked cases that asserted call counts and mock ordering are removed;
the pure retry-after parsing stays a unit test.
2026-10-22 05:32:09 +02:00
Marc-André Bombeck c0c0fdb421 test(integration): move the account preference route tests onto Postgres
Fourteen route tests for the /api/auth/me preference family replaced Prisma
with a mock and asserted the calls it received. Their claims are now shown
through the real handler on the stored row and the response: validation
refusals with their codes, normalisation and defaults on read, optimistic
lock conflicts against a real second writer, rate limits driven to their
first refusal in the Postgres limiter, audit rows read back, cache entries
evicted, and the jobs a change queues observed on a real pg-boss queue.

Assertions that were only about the shape of a Prisma call are replaced by
the behaviour they stood for, and two that described a state only a mock
can reach (a missing column) by the stored value that state resolves to.
2026-10-22 05:31:59 +02:00
Marc-André Bombeck 09f088e8a7 fix(auth): record a self sign-out as one in the session revoke audit
DELETE /api/auth/me/sessions/{id} takes the session's public handle, an HMAC
of the row id, and compared that handle with the caller's row id to decide
whether the caller signed out the session it was using. The two never match,
so every revoke was audited and annotated with self: false, including the
ones where a person ended their own session. The handle is compared with the
handle of the caller's session now.
2026-10-22 05:31:51 +02:00
Marc-André Bombeck f353f55212 test(integration): sessions, devices, security activity and FHIR against Postgres
The mocked route tests for the session list and revoke, the device list and
revoke, the security-activity feed and the FHIR surface move onto the kit.
Each claim is now shown on the stored rows and on the next request: a
revoked session, device login or token is refused when it is presented again,
another account's session or device answers 404 and stays signed in, the
current-session and current-device flags follow the credential that anchors
them, and no row id, full IP or push token reaches a response.

The FHIR read set, covered against a database only by the scope check until
now, is driven with seeded readings and medications: every resource carries
the seeded values, another account's rows never appear, a fhir:read token
reaches every data route and an unrelated narrow token is refused, the module
gate closes all eleven data routes, and $everything serves the same entries
as the document builder with references that resolve inside the response.
The module-gate inventory now names the new file as its behavioural proof.
2026-10-22 05:31:09 +02:00
Marc-André Bombeck b179e34f58 test(integration): round-trip every account setting through the API and a backup
One table holds an entry per write route, and every User column the backup
classifies as a setting appears in exactly one entry; a new setting column
fails the inventory case until it has one. For each entry the file sets a
non-default value through its write route, reads it back through its read
route and through /api/auth/me where that publishes it, and checks the stored
column moved off what a fresh account holds.

The backup leg runs through the routes too: the portable full backup a
person downloads, the operator's upload, and the restore into an account
recreated under the same id. Every setting must read back identically on the
restored account, through the same routes and the account payload, and every
stored column must come back with its value.
2026-10-22 05:30:40 +02:00
Marc-André Bombeck 3d837cdd77 test(kit): call route handlers through the real API kit against Postgres
tests/kit/ holds typed fixture factories and callRoute(handler, options).
The factories write through the application's Prisma client, so a fixture has
the shape production stores and the compiler checks it; sessions and Bearer
tokens are minted by createSession and issueApiToken, so a fixture cannot
drift into a shape authentication no longer accepts.

callRoute builds a real NextRequest, fills the next/headers stand-in from the
auth fixture and runs the exported handler, apiHandler included. Every call is
also a contract check: the status must be declared for that path and method in
the OpenAPI registry, a JSON body must parse with the registered schema, and a
key the schema does not declare fails the call. The scope gate's generic 403,
documented on the shared 401, is the one answer accepted without its own
entry. kit-contract.test.ts proves each refusal can fire.

The token list and revoke move onto the kit. The mocked unit test asserted
the list's where clause and proved the API switch is not consulted by reading
api-handler.ts for a string; the kit test authenticates a Bearer token while
the switch is off, revokes it, and shows it refused on the next request.
2026-10-22 05:30:31 +02:00
Marc-André Bombeck a6787cfc03 test(integration): run files in parallel, one cloned database per worker
Global setup migrates one template database and reads what a fresh install
holds: its schemas, its tables and the rows the migrations seed. Each Vitest
worker clones the template on its first file with CREATE DATABASE ... TEMPLATE
and keeps the clone for the rest of the run, so files run in parallel without
seeing each other's rows, advisory locks or queue schema. Schemas created at
runtime (pg-boss, migration probes) are dropped when the next file starts.

The truncate list is the catalogue now, every table in public except the
migration bookkeeping, instead of a hand-kept list of 42 of 166 tables. The
seeded catalogues are put back after each truncate in foreign-key order, so a
test starts from what a fresh install has. The settings singleton is the one
seeded table a test starts without, declared with its reason.

next/headers and the boot compatibility probe are mocked once in the setup
file. Two lock-wait probes now look at their own database only, since other
workers' backends share pg_stat_activity.
2026-10-22 05:30:00 +02:00
Marc-André Bombeck 45ea61261e build(bundle): gate every page's first load and the distinct client code
The gate summed every emitted chunk once and watched five pages. The sum
counts a module once per chunk group it lands in, so it rose whenever a
page got lighter by moving code into on-demand chunks, and it needed a
raise in most releases while no reader downloaded more; and a heavy import
into a page outside the five went unseen.

scripts/check-bundle-budget.mjs now measures:

- the first load of every page: rootMainFiles plus the page's
  client-reference chunks, each file counted once by content hash;
- p50, p95 and max of those first loads across pages;
- the distinct client code: every chunk evaluated in a node:vm sandbox
  with a stub TURBOPACK array, each module factory counted once by its
  module id (not its text: each chunk is minified on its own, so copies of
  one module differ in mangled names), catalogs excluded, gzipped once.

The summed aggregate is still printed but no longer gated. The catalog,
per-catalog and one-Recharts checks are unchanged. `--write-baselines`
rewrites the per-page baselines, `--dir` measures another build directory,
`--json` writes the report.

bundle-budget.json is re-based from the v1.44.0 build (local arm64): 110
pages, first load p50 336, p95 437, max 451 KB gz; distinct client code
1904 KB gz in 1224 modules; summed aggregate 3875 KB for comparison. Every
page has a baseline with the same 20 KB allowance; caps are p95 447, max
461 and distinct code 1930 KB. A page without a baseline fails until one
is written in the same change.

Proof: importing zod into the weight insights page measured
/insights/weight at 427.8 KB gz, 88.8 KB over its 339 KB baseline, and the
gate failed; the old gate passed the same build, because the page was not
watched and the summed aggregate did not move (3875 KB). The import was
removed again.
2026-10-22 04:36:21 +02:00
Marc-André Bombeck f64b49cf55 test(guards): freeze runtime import cycles and re-export-only modules
The runtime import graph (static value imports and re-exports, side-effect
imports, dynamic import() with a literal; `import type` erased) is built
with the TypeScript compiler's module resolution under the `@/*` mapping,
over every production module. No new dependency.

- Cycles: 13, counted one per back edge of a sorted depth-first walk (the
  number `madge --circular` reports), in 7 strongly connected components:
  the audit/geo/notifications/managed-profiles cluster (10 modules), the
  compliance facade cluster (9), command palette ↔ store ↔ shortcuts,
  modules ↔ cycle gate, boss-instance ↔ boss-ready, mood aggregates ↔
  patterns, features ↔ signals-of-day. A new component or a component that
  draws in another module fails whatever the count says; a lower count or a
  shrunk component fails until written back. The target is zero.
- Re-export-only modules (every statement an `export … from`): 16 listed;
  a new one fails.

Proof: two planted modules importing each other failed with
"lib/zz-probe/a.ts <-> lib/zz-probe/b.ts", a planted `export * from "./a"`
module failed the re-export ratchet, and a baseline raised to 14 cycles
failed with the write-back message until RATCHET_WRITE=1 restored it.
2026-10-22 04:29:57 +02:00
Marc-André Bombeck 2604c3e3c6 test(guards): ratchet source size, test realism and version-stamp comments
Add a ratchet mechanism (helpers/ratchet.ts): per-key counts against a JSON
baseline under src/__tests__/baselines/. A new key or a higher count fails;
a lower count fails until it is written back with
`RATCHET_WRITE=1 pnpm vitest run <guard>`, which only ever lowers numbers.
Raising one is a hand edit in a reviewed change. The baselines are
machine-written, so prettier leaves them alone.

helpers/source-metrics.ts measures the production tree through the
TypeScript compiler: the file list (dot directories included, tests and
generated code out), line counts, every function node with a stable
"outer > inner" name and its length, and the runtime import graph.

Guards and their baselines, measured on this tree:

- source-size-ratchet: 129 files over 800 lines and 415 functions over 200
  lines (catalog and data files exempt, each named with its reason; slack
  25 and 10 lines before a shrink must be written back).
- test-realism-ratchet: 321 route tests mocking @/lib/db wholesale, 138
  stubbing @/lib/api-handler, 6014 `as never` casts in 570 test files, and
  400 test files that read a source file by path (reads through readSource
  are not counted).
- version-stamp-ratchet: 10634 comment lines in 2985 files that open with a
  release version; none may be added.

Proof each can fail: a planted 953-line file with a 253-line function failed
both size ratchets, and one line appended to app/page-client.tsx failed the
file ratchet (2326 → 2327); a planted route test with vi.mock("@/lib/db")
and one `as never` failed both of those ratchets; a planted comment
"// v1.45.0 — probe stamp" failed the stamp ratchet. Raising one as-never
baseline entry by 5 failed with the write-back message, and RATCHET_WRITE=1
restored the file byte for byte. All probes were removed again.
2026-10-22 04:29:47 +02:00
Marc-André Bombeck f3d7ab31e2 test(guards): make path-pinned source reads fail when their subject moves
A test that reads a source file by path and asserts an absence
(`expect(source).not.toMatch(…)`) stays green after a split: the code it
watched moved next door and the old path no longer holds it. A missing file
throws; a file that kept its name but lost its contents does not.

- `readSource(path, anchors)` in helpers/source-files.ts reads one file and
  requires every anchor (a string or pattern the watched code carries) to
  match at least once; it throws on a missing file, a lost anchor, or a read
  without an anchor.
- `readSourceFolder(dir, { floor })` is the folder-scan form for a subject
  that was split into several files.
- The twelve reads that were asserted only negatively now go through
  `readSource` with an anchor: the batch route, the integration-sync
  registrar, the dashboard client, the mood insights client, the sleep
  overview and sleep page, the sidebar, the baseline form, the account
  section (two guards), api-handler's requireAuth and the Google Health sync.
- The two schema parsers the wipe and enum guards share now refuse a parse
  of the real schema that finds fewer than 50 models (and 10 enums), so a
  moved file or a drifted block regex cannot leave those guards comparing
  an allowlist against nothing. Synthetic schemas a test writes stay exempt.

Proof: renaming `setSaving` in the baseline form made the converted
confirm-failure guard throw "the file no longer contains setSaving"; the
helper's own test plants a missing file, a lost string anchor, a lost regex
anchor, an empty anchor list and an under-floor folder, each of which throws.
2026-10-22 04:29:35 +02:00
Marc-André Bombeck c86c7d1a79 refactor(lint): enforce the lib → components and client → server boundaries
Add the in-repo rule `healthlog/import-boundaries`:

- a file under src/lib may not import src/components or src/app (alias,
  relative, re-export and dynamic forms, type-only included);
- a "use client" file may not make a value import of the database client,
  the crypto module, the Prisma client entry, the job queue, pg, pg-boss or
  next/headers. Type-only imports and the Prisma enums entry stay legal.

The four lib files that still import upward are listed in the rule's allow
map with a reason each; a listed file that stops importing upward is
reported, so the map can only shrink.

Five of the nine upward imports are gone by moving pure modules to their
layer, without behaviour change:

- supply-runway estimate → src/lib/medications/supply-runway.ts
  (read by the low-stock job and the medication list read);
- weekday BYDAY tokens → src/lib/medications/scheduling/weekday-tokens.ts
  (read by the medication extraction prompt);
- settings section slugs → src/lib/settings/section-slugs.ts
  (read by the record-settings classification);
- the command palette index → src/components/command-palette/ (it is a
  client index built from component registries).

Proof the rule can fail: an import of @/components/ui/card appended to
src/lib/medications/supply-runway.ts reported libUpward, and a planted
"use client" file importing @/lib/db reported clientServerOnly; both were
removed again.
2026-10-22 04:15:11 +02:00
dareoon 12065bc0c7 feat(i18n): add Brazilian Portuguese support to medication and insight flows
Add pt-BR locale coverage across medication extraction, coach prose
grounding, no-key insight fallbacks, coach read dates, and lab reference
range parsing.

- Accept pt-BR natively in NaturalLanguageExtractorProps.
- Add Brazilian Portuguese population-norm framing patterns.
- Add pt-BR no-assessment fallback copy for all insight floors.
- Add pt-BR expected date coverage to the Coach read strip test.
- Add pt-BR lab range prose vocabulary.
- Keep existing locale behavior and safety-contract coverage unchanged.
2026-10-19 22:32:17 -03:00
dareoon da3edbf729 chore(i18n): revisão de pt-BR e normalização de 'toma' para 'dose' 2026-10-19 18:28:51 -03:00
dareoon 1223476280 i18n(pt-BR): translate demoBanner 2026-10-19 15:34:27 -03:00
dareoon 29ac3af002 i18n(pt-BR): translate offlineBanner 2026-10-19 15:34:27 -03:00
dareoon 5d479491f8 i18n(pt-BR): translate notFound 2026-10-19 15:34:27 -03:00
dareoon 985c047a4c i18n(pt-BR): translate errorBoundary 2026-10-19 15:34:27 -03:00
Dareeon a5344b7c85 i18n(pt-BR): translate dashboard section 2026-10-19 15:25:27 -03:00
Dareeon 5a2ccb657c i18n(pt-BR): translate nav and auth sections 2026-10-19 14:05:46 -03:00
Dareeon 13dac2d89c i18n(pt-BR): translate doctorReport section 2026-10-19 14:01:06 -03:00
Dareeon 50e901f807 i18n(pt-BR): add initial translation (common, apiErrors) 2026-10-19 13:46:00 -03:00
929 changed files with 130448 additions and 31128 deletions

No files matched your search

+6
View File
@@ -28,6 +28,12 @@ playwright/.cache
playwright.config.ts
vitest.config.mts
vitest.integration.config.mts
vitest.mutation.config.mts
# The unit setup imports tests/ (excluded above); the image never runs it and
# the build's type check must not see a half-present test harness.
vitest.setup.ts
stryker.config.mjs
stryker.floors.json
tests
tests/
e2e
+4 -3
View File
@@ -1,16 +1,16 @@
name: Translation feedback
description: Report a wording, grammar, or register problem in HealthLog's AI-initial locales (FR / ES / IT / PL / KO / NL).
description: Report a wording, grammar, or register problem in HealthLog's community-maintained locales (FR / ES / IT / PL / KO / NL / PT-BR).
title: "[Translation] "
labels: ["i18n", "translation-feedback"]
body:
- type: markdown
attributes:
value: |
HealthLog ships German and English as maintainer-curated translations. French, Spanish, Italian, Polish, Korean, and Dutch ship as **AI-initial translations** — the prose passes the locale-integrity test (full key parity, no empty values) but has not been hand-reviewed for register or regional vocabulary.
HealthLog ships German and English as maintainer-curated translations. French, Spanish, Italian, Polish, Korean, Dutch, and Brazilian Portuguese ship as **AI-initial, community-maintained translations** — the prose passes the locale-integrity test (full key parity, no empty values) but has not been hand-reviewed for register or regional vocabulary.
This template captures concrete fixes you would like to see. Phrasing nuance, regional vocabulary (e.g. "Tú" vs "Usted"), gendered forms, and domain-specific health terms are all in scope. The maintainer applies fixes during the regular release cycle.
For wholly new locale requests (e.g. "please add Portuguese"), open a [Discussion](https://github.com/MBombeck/HealthLog/discussions) instead.
For wholly new locale requests (e.g. "please add Swedish"), open a [Discussion](https://github.com/MBombeck/HealthLog/discussions) instead.
- type: dropdown
id: locale
attributes:
@@ -22,6 +22,7 @@ body:
- Polish (pl)
- Korean (ko)
- Dutch (nl)
- Brazilian Portuguese (pt-BR)
- Other (specify under "Surface")
validations:
required: true
+16 -16
View File
@@ -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
View File
@@ -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
+30 -13
View File
@@ -10,20 +10,23 @@ permissions:
contents: read
jobs:
# The suite outgrew a single runner, then a half-suite outgrew a slow one,
# and then a third of it did too. Three shards of about 68 files cleared in
# 13 minutes on an ordinary runner, but a slow runner day ran every test at
# twice its usual duration and the first shard was still working through
# its list when the 25-minute guard cut it off: three of four pushes to main
# between 2026-08-31 and 2026-09-01 failed that way, with green tests logged
# up to the last second. Four shards keep each slice around 50 files, and
# the guard sits at three times the ordinary run rather than under two. A
# wedged test still surfaces through vitest's own 60-second budget, so the
# job timeout only needs to catch a container that never boots. Each shard
# boots its own Postgres testcontainer.
# Four shards of about equal duration, each running its files in parallel
# against one database per worker. The shards used to be four slices of
# equal file count, run serially: 954 s to 1 449 s, because a slice holding
# the sharing matrix and the backup files took half again as long as one
# holding small files. `--shard` now goes through the duration sequencer
# (`tests/durations/sequencer.ts`, timings in
# `tests/durations/integration.json`), and every file in a shard runs on
# one of three workers, each against its own clone of the migrated template
# (`tests/integration/worker-database.ts`). Three workers leave a core of
# the four-core runner to the Postgres container. The job guard stays well
# above an ordinary run; a wedged test still surfaces through vitest's own
# 60-second budget, so the guard only needs to catch a container that never
# boots. Each shard boots its own Postgres testcontainer and uploads its
# JSON report, which `scripts/test-durations.mjs` turns into the timing file.
shard:
runs-on: ubuntu-24.04
timeout-minutes: 40
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
@@ -48,8 +51,13 @@ jobs:
# vitest a stray positional argument and the shard flag never applies —
# both "shards" then run the full suite. Proven by a paired local run
# before this workflow shipped.
- run: pnpm exec vitest run --config vitest.integration.config.mts --shard=${{ matrix.shard }}/4
- run: >-
pnpm exec vitest run --config vitest.integration.config.mts
--shard=${{ matrix.shard }}/4
--reporter=default --reporter=json
--outputFile.json=integration-report-${{ matrix.shard }}.json
env:
HEALTHLOG_INTEGRATION_WORKERS: "3"
# The integration suite boots a Postgres testcontainer and points
# Prisma at it via DATABASE_URL set inside `tests/integration/setup.ts`.
# These env vars satisfy code paths that read them at module load.
@@ -63,6 +71,15 @@ jobs:
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000"
SESSION_SECRET: "integration-tests"
- name: Upload the timing report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: integration-report-${{ matrix.shard }}
path: integration-report-${{ matrix.shard }}.json
if-no-files-found: ignore
retention-days: 14
# Branch protection requires a check named exactly "integration". This job
# carries that name and answers for every shard: it fails when any shard
# failed or was cancelled, so a half-green matrix cannot satisfy the gate.
+53
View File
@@ -0,0 +1,53 @@
name: Mutation tests
# Stryker on the core libraries (stryker.config.mjs): encryption at rest,
# medication compliance, rollups, source ranking, unit conversion and backup
# validation. Too slow for a pull request; it runs once a night and on
# demand, and fails when a module's score drops below its floor in
# stryker.floors.json. The HTML report is uploaded either way.
on:
schedule:
- cron: "23 1 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
mutation:
runs-on: ubuntu-24.04
timeout-minutes: 120
name: mutation (core libraries)
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
- name: Run Stryker
run: pnpm exec stryker run --concurrency 4
# Without pipefail the step takes the exit status of `tee`, which is
# always 0, so a module under its floor could never fail the run.
- name: Check the per-module floors
shell: bash
run: |
set -o pipefail
node scripts/mutation-floors.mjs | tee -a "$GITHUB_STEP_SUMMARY"
- name: Upload the report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: mutation-report
path: reports/mutation/
if-no-files-found: ignore
retention-days: 30
+94 -30
View File
@@ -15,11 +15,72 @@ permissions:
contents: read
jobs:
quality:
name: Lint, Typecheck & Test
# The static checks: everything in the old single quality job except the
# unit suite, which runs sharded below.
static:
name: Lint, Typecheck & OpenAPI
runs-on: ubuntu-24.04
if: github.event_name != 'schedule'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
# Blocking since v1.15.20 — the settings-split refactor that kept
# this warn-only is long done and the tree lints clean.
- name: ESLint
run: pnpm lint
# Prettier formatting gate. The format pass excludes generator-owned
# artifacts via .prettierignore (notably docs/api/openapi.yaml, which
# the OpenAPI drift check below enforces byte-for-byte).
- name: Prettier format check
run: pnpm format:check
- name: TypeScript strict check
run: pnpm typecheck
# OpenAPI spec drift gate — hard-fail (flipped from warn-only in v1.4.25).
#
# The Zod registry is the source of truth for the public API contract.
# Any route signature change must be reflected in `docs/api/openapi.yaml`
# so downstream consumers (notably the v1.5 iOS Swift codegen) stay in
# lockstep. If this step fails, run `pnpm openapi:generate` locally and
# commit the regenerated spec.
- name: OpenAPI spec drift check
run: |
pnpm openapi:generate
if ! git diff --exit-code docs/api/openapi.yaml; then
echo "::error::OpenAPI drift detected — regenerate the spec via 'pnpm openapi:generate' and commit the result. The Zod registry under src/lib/openapi/ is the source of truth for the public API contract."
exit 1
fi
echo "::notice::OpenAPI spec in sync with Zod source schemas."
# The unit suite in three slices of about equal duration. `--shard` goes
# through the duration sequencer in `vitest.config.mts`, which deals files
# out by their measured time (`tests/durations/unit.json`) instead of by
# count. Each slice uploads its JSON report, which is what
# `scripts/test-durations.mjs` regenerates the timing file from.
unit:
runs-on: ubuntu-24.04
if: github.event_name != 'schedule'
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
name: unit shard ${{ matrix.shard }}/3
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -38,7 +99,8 @@ jobs:
# real browser and measures the boxes. Without a browser here it caught
# its own launch failure and passed anyway, so from its first day until
# v1.38 it measured nothing on any run of this job. The browser is part
# of the gate now; the test refuses to skip when CI is set.
# of the gate now; the test refuses to skip when CI is set. Every slice
# installs it, because the sequencer decides which slice the file lands in.
#
# Headless shell only: `chromium.launch()` resolves to
# chromium_headless_shell, which is what the failing runs named, and the
@@ -54,38 +116,40 @@ jobs:
- run: pnpm exec playwright install --only-shell chromium
# Blocking since v1.15.20 — the settings-split refactor that kept
# this warn-only is long done and the tree lints clean.
- name: ESLint
run: pnpm lint
# Prettier formatting gate. The format pass excludes generator-owned
# artifacts via .prettierignore (notably docs/api/openapi.yaml, which
# the OpenAPI drift check below enforces byte-for-byte).
- name: Prettier format check
run: pnpm format:check
- name: TypeScript strict check
run: pnpm typecheck
# vitest directly, not `pnpm test --`: pnpm hands the separator through
# literally and the shard flag would never reach vitest.
- name: Unit tests
run: pnpm test
run: >-
pnpm exec vitest run --shard=${{ matrix.shard }}/3
--reporter=default --reporter=json
--outputFile.json=unit-report-${{ matrix.shard }}.json
# OpenAPI spec drift gate — hard-fail (flipped from warn-only in v1.4.25).
#
# The Zod registry is the source of truth for the public API contract.
# Any route signature change must be reflected in `docs/api/openapi.yaml`
# so downstream consumers (notably the v1.5 iOS Swift codegen) stay in
# lockstep. If this step fails, run `pnpm openapi:generate` locally and
# commit the regenerated spec.
- name: OpenAPI spec drift check
- name: Upload the timing report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: unit-report-${{ matrix.shard }}
path: unit-report-${{ matrix.shard }}.json
if-no-files-found: ignore
retention-days: 14
# Branch protection requires a check named exactly "Lint, Typecheck & Test".
# That used to be one job running everything in turn; it is now this one,
# which answers for the static checks and every unit slice, so a half-green
# set cannot satisfy the gate.
quality:
name: Lint, Typecheck & Test
runs-on: ubuntu-24.04
needs: [static, unit]
if: always() && github.event_name != 'schedule'
timeout-minutes: 5
steps:
- name: The static checks and every unit slice finished green
run: |
pnpm openapi:generate
if ! git diff --exit-code docs/api/openapi.yaml; then
echo "::error::OpenAPI drift detected — regenerate the spec via 'pnpm openapi:generate' and commit the result. The Zod registry under src/lib/openapi/ is the source of truth for the public API contract."
if [ "${{ needs.static.result }}" != "success" ] || [ "${{ needs.unit.result }}" != "success" ]; then
echo "static: ${{ needs.static.result }}, unit: ${{ needs.unit.result }}"
exit 1
fi
echo "::notice::OpenAPI spec in sync with Zod source schemas."
# Not on pull requests. `pnpm audit` answers a question about the published
# advisory database, not about the diff: the same lockfile passes on Monday
+178
View File
@@ -0,0 +1,178 @@
name: Tests (nightly, shuffled)
# Looks for what a pull request run cannot see by design.
#
# - Order dependence. Both suites run with files and tests in a random order
# (`--sequence.shuffle`), seeded from the clock and printed at the top of
# the log and in the job summary. A failure that does not reproduce on a
# pull request reproduces here with `--sequence.seed=<seed>`.
# - New property-test inputs. Pull requests run every fast-check property
# with a fixed seed; this run picks a fresh one (`FC_SEED`) and five times
# the cases, and fast-check prints the seed and the shrunk counterexample
# on a failure (see tests/property/fast-check.ts).
# - Stale shard timings. The integration reports of this run are merged into
# a regenerated `tests/durations/integration.json`, uploaded as an
# artifact for a maintainer to commit when the shards drift apart.
#
# Not a gate: nothing here blocks a merge. A red run is an issue to open.
on:
schedule:
- cron: "41 2 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
unit:
runs-on: ubuntu-24.04
timeout-minutes: 45
name: unit (shuffled)
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
- name: Cache Playwright headless shell
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: playwright-shell-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
playwright-shell-${{ runner.os }}-
- run: pnpm exec playwright install --only-shell chromium
- name: Pick the seeds
run: |
echo "TEST_SEED=$(date +%s)" >> "$GITHUB_ENV"
echo "FC_SEED=$(( $(date +%s%N) % 2147483647 ))" >> "$GITHUB_ENV"
- name: Unit tests, shuffled
shell: bash
env:
# Five times each property's own case count (tests/property/fast-check.ts).
FC_RUNS_FACTOR: "5"
run: |
set -o pipefail
echo "Order seed ${TEST_SEED}, property seed ${FC_SEED}" | tee -a "$GITHUB_STEP_SUMMARY"
pnpm exec vitest run --sequence.shuffle --sequence.seed="${TEST_SEED}" \
--reporter=default --reporter=json --outputFile.json=unit-report.json
- name: Upload the timing report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: nightly-unit-report
path: unit-report.json
if-no-files-found: ignore
retention-days: 14
integration:
runs-on: ubuntu-24.04
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
name: integration shard ${{ matrix.shard }}/4 (shuffled)
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
# One seed per run, shared by the shards through the run id, so every
# shard shuffles the same way and a failure names one seed to replay.
- name: Pick the seeds
run: |
echo "TEST_SEED=${{ github.run_id }}" >> "$GITHUB_ENV"
echo "FC_SEED=$(( ${{ github.run_id }} % 2147483647 ))" >> "$GITHUB_ENV"
- name: Integration tests, shuffled
shell: bash
env:
HEALTHLOG_INTEGRATION_WORKERS: "3"
# Four times each property's own case count; a property's timeout
# grows by the same factor (`propertyTimeout`).
FC_RUNS_FACTOR: "4"
# Quoted for the reason given in integration.yml.
API_TOKEN_HMAC_KEY: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000"
SESSION_SECRET: "integration-tests"
run: |
set -o pipefail
echo "Order seed ${TEST_SEED}, property seed ${FC_SEED}" | tee -a "$GITHUB_STEP_SUMMARY"
pnpm exec vitest run --config vitest.integration.config.mts \
--shard=${{ matrix.shard }}/4 \
--sequence.shuffle --sequence.seed="${TEST_SEED}" \
--reporter=default --reporter=json \
--outputFile.json=integration-report-${{ matrix.shard }}.json
- name: Upload the timing report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: nightly-integration-report-${{ matrix.shard }}
path: integration-report-${{ matrix.shard }}.json
if-no-files-found: ignore
retention-days: 14
# Merge the timings of this run into fresh timing files. Uploaded, not
# committed: the workflow has read-only access, and a maintainer decides
# whether the drift is worth a commit.
durations:
runs-on: ubuntu-24.04
needs: [unit, integration]
if: always()
timeout-minutes: 10
name: regenerate shard timings
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: nightly-*-report*
path: reports
- name: Regenerate
shell: bash
run: |
set -o pipefail
shopt -s nullglob
integration=(reports/nightly-integration-report-*/*.json)
unit=(reports/nightly-unit-report/*.json)
if [ ${#integration[@]} -gt 0 ]; then
node scripts/test-durations.mjs integration "${integration[@]}"
fi
if [ ${#unit[@]} -gt 0 ]; then
node scripts/test-durations.mjs unit "${unit[@]}"
fi
git diff --stat -- tests/durations | tee -a "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: test-durations
path: tests/durations/*.json
retention-days: 14
+3
View File
@@ -98,3 +98,6 @@ next-env.d.ts
# same reason in a different tool.
/.wt-*/
/.worktrees/
# Mutation testing (stryker.config.mjs): reports and the sandbox copy.
/reports/mutation
/.stryker-tmp
+3
View File
@@ -11,6 +11,9 @@ paths = [
# All test files contain hardcoded fake tokens / idempotency-keys.
'''(^|/)__tests__/''',
'''\.test\.ts$''',
# Golden copies of the backup file written by the parity test: synthetic
# fixture values, and every `…Encrypted` value already opened to its text.
'''^tests/integration/__golden__/''',
# OpenAPI spec contains example tokens (`hlk_example…`) used to document the schema.
'''^docs/api/openapi\.yaml$''',
]
+18
View File
@@ -16,3 +16,21 @@ docs/api/openapi.yaml
# Build-generated service worker (the PWA build emits it).
public/sw.js
# Ratchet baselines, written by the guards themselves (RATCHET_WRITE=1). The
# writer's JSON layout is the canonical one, so a write-back never fails the
# format check.
src/__tests__/baselines
# Benchmark results, written by scripts/bench/run.ts and committed as they
# come out, so a re-run diffs cleanly against the last one.
scripts/bench/results
# Golden copies of the backup file and the restored account, written by the
# parity test itself (BACKUP_PARITY_WRITE=1), so a rewrite diffs cleanly.
tests/integration/__golden__
# inlang writes its working files (cache, metadata, README) next to the
# project settings when its CLI or SDK runs; only settings.json is tracked.
project.inlang/*
!project.inlang/settings.json
+173
View File
@@ -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
View File
@@ -121,7 +121,7 @@ Contributors: branch off `main`; PRs target `main`.
## Translations
HealthLog ships with English (`en`) and German (`de`) maintained by the project, plus French (`fr`), Spanish (`es`), Italian (`it`), Polish (`pl`), Korean (`ko`), and Dutch (`nl`) that are **AI-initial, community-maintained**. The maintained locales are reviewed line-by-line; the community-maintained ones surface a small banner inviting users to improve them.
HealthLog ships with English (`en`) and German (`de`) maintained by the project, plus French (`fr`), Spanish (`es`), Italian (`it`), Polish (`pl`), Korean (`ko`), Dutch (`nl`), and Brazilian Portuguese (`pt-BR`) that are **AI-initial, community-maintained**. The maintained locales are reviewed line-by-line; the community-maintained ones surface a small banner inviting users to improve them.
### Improving an existing locale
@@ -134,16 +134,36 @@ To suggest a fix or improvement:
3. Edit `messages/<locale>.json`. Keep the JSON valid; the i18n integrity test will flag missing or extra keys against `en.json` before CI accepts the PR.
4. Open a PR titled `i18n(<locale>): <area>`. Describe what was unclear, awkward, or wrong in the previous wording.
### Translating in the browser
Every locale can be edited in [Fink](https://fink.inlang.com/?repo=MBombeck/HealthLog&project=project.inlang), inlang's browser editor, without cloning the repository. Fink reads `project.inlang/settings.json`, which points it at `messages/<locale>.json`, shows each message next to its translations, and flags strings that are missing or use other placeholders than English.
1. Open the link and sign in with GitHub.
2. Without write access to this repository, choose **Fork**. Fink asks you to install the Inlang GitHub App on your own account so it can push to your fork.
3. Edit, then choose **Push**. Fink commits to the fork; **Open pull request** takes you to GitHub's compare page, where you submit the change against `main`.
Fink writes the same nested JSON this repository uses and changes only the lines you edited. A string you add for a key the locale did not have yet goes to the end of its section instead of the position it has in `en.json`, which is harmless.
What a reviewer checks on a translation pull request:
- Only `messages/*.json` changed, and only the strings the description names.
- Every `{placeholder}` of the English string is still there and spelled the same. The i18n integrity test fails the build otherwise.
- Medical terms are the ones the language's clinical guidelines use, and the register matches the rest of the locale (`du` in German, `você` in Brazilian Portuguese).
- Words the app or the Telegram bot matches literally stay as they are: slash commands, `taken <name>`, and the unit symbols.
- Machine translation is fine as a first draft, but someone who reads the language has gone through every string before the pull request is opened, and the description says so.
- Validated questionnaire items (the PHQ-9, GAD-7, WHO-5 and SCI items, their answer options and their stems under `mentalHealth.items`, `mentalHealth.options`, `mentalHealth.gad7Options`, `mentalHealth.who5Options`, `mentalHealth.sciOptions`, `mentalHealth.stems`, `mentalHealth.functional*`) are never translated freely, not even as a draft. A language gets them only from a published, licensed validated version, copied verbatim and named in `src/lib/mental-health/instruments.ts`; until then the app shows the English original and the bundle carries none of those keys, which the i18n tests enforce.
To run the checks Fink shows on a local checkout: `npx @inlang/cli@3.4.4 check --project ./project.inlang`. It reads the files and changes nothing.
### Adding a new locale
If you want to introduce a locale that isn't shipped (e.g. `pt-BR`, `cs`):
If you want to introduce a locale that isn't shipped (e.g. `sv`, `cs`):
1. Open an issue first using the **Translation request** template. We'll confirm the locale is in scope and decide whether it joins the AI-initial-community-maintained tier or the maintained tier.
2. Copy `messages/en.json` to `messages/<locale>.json`.
2. Copy `messages/en.json` to `messages/<locale>.json`, then delete the questionnaire wording the new language has no validated version of (the test output lists the keys).
3. Translate. AI assistance is welcome as a starting point but the PR description should note whether it's an AI-initial pass or a fully human-reviewed one — that information drives the banner state.
4. Add the locale to the supported list in `src/lib/i18n/locales.ts` (or wherever the registry lives — grep `SUPPORTED_LOCALES`).
5. The Coach system prompts at `src/lib/ai/prompts/coach-prompt.ts` and `src/lib/ai/prompts/insight-generator.ts` and the doctor-report PDF strings need translation too. Include those in the same PR.
6. Run `pnpm test -- i18n-integrity` locally before submitting.
4. Add the locale to `locales` in `src/lib/i18n/config.ts` and to `locales` in `project.inlang/settings.json`, then run `pnpm typecheck`. Every per-locale table that still lacks the new locale fails to compile, and that list is the work: date and number formatting, the reply-language footers of the Coach and insights prompts, the outbound safety banks, the prose-grounding patterns and the no-key fallback copy. Include those in the same PR.
5. Run `pnpm test` before submitting. The i18n guards hold the new bundle to the English keys and placeholders.
### Style guide for translators
@@ -156,7 +176,7 @@ If you want to introduce a locale that isn't shipped (e.g. `pt-BR`, `cs`):
### Long-term
When the community grows beyond ad-hoc PRs, we'll spin up a self-hosted [Weblate](https://weblate.org/) instance. That replaces the JSON-PR workflow with a web UI but keeps every translation under our own roof — no third-party telemetry. Tracking issue: `v1.6 — Weblate self-hosted on edge-01`.
Fink covers browser editing today. It edits the files in this repository directly and keeps no copy of the translations anywhere else. If the community outgrows it, a self-hosted [Weblate](https://weblate.org/) instance remains the fallback. Tracking issue: `v1.6 — Weblate self-hosted on edge-01`.
## Code of Conduct
+18 -1
View File
@@ -4,6 +4,9 @@ RUN corepack enable && corepack prepare pnpm@11.15.1 --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# The dependency patches pnpm-workspace.yaml names; the frozen install
# refuses to run without them.
COPY patches patches
COPY scripts/prepare.mjs scripts/prepare.mjs
RUN pnpm install --frozen-lockfile --prod=false
@@ -57,6 +60,11 @@ RUN node -p "require('/app/node_modules/pdfjs-dist/package.json').version" \
# The same for @napi-rs/canvas, whose native binary must match its JS loader.
RUN node -p "require('/app/node_modules/@napi-rs/canvas/package.json').version" \
> /app/.canvas-version
# Every shipped locale's message catalog is present and parses, checked
# against the locale list in the source; the list is written out so the runner
# stage can check the catalogs the image actually carries.
RUN node scripts/verify-message-catalogs.mjs --dir messages \
--config src/lib/i18n/config.ts --write /app/.message-locales
# ── Stage 3: Production runner ─────────────────────────────
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS runner
@@ -105,6 +113,15 @@ RUN addgroup --system --gid 1001 nodejs && \
# Copy built assets
COPY --from=builder /app/public ./public
# The server reads its message catalogs from here on first use, one locale at
# a time (src/lib/i18n/server-catalogs.ts); they are not bundled.
COPY --from=builder /app/messages ./messages
# A missing or broken catalog is only noticed when a page asks for that
# locale, so check the copies in this stage against the builder's list.
COPY --from=builder /app/.message-locales /app/scripts/verify-message-catalogs.mjs /tmp/catalog-check/
RUN node /tmp/catalog-check/verify-message-catalogs.mjs --dir /app/messages \
--locales /tmp/catalog-check/.message-locales && \
rm -rf /tmp/catalog-check
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Next's standalone output must carry the externalized worker dependencies.
@@ -265,7 +282,7 @@ RUN mkdir -p /opt/prisma-cli && \
npm pkg set 'overrides.mysql2=^3.22.0' && \
npm pkg set 'overrides.@hono/node-server=^1.19.15' && \
npm pkg set 'overrides.valibot=^1.4.2' && \
npm install --omit=dev prisma@7.10.0 @prisma/engines@7.10.0 tsx@4.23.1 dotenv@18.0.6 && \
npm install --omit=dev prisma@7.10.1 @prisma/engines@7.10.1 tsx@4.23.1 dotenv@18.0.8 && \
ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx && \
ln -sfn /opt/prisma-cli/node_modules/dotenv /app/node_modules/dotenv && \
ln -sfn /opt/prisma-cli/node_modules/prisma /app/node_modules/prisma && \
+9 -8
View File
@@ -56,7 +56,7 @@ Try the [live demo](https://demo.healthlog.dev) to see a working install — sig
- **AI insights you own.** Daily briefing, health scores, correlations, and a conversational coach grounded in your own measurements and interpreted against cited guideline reference bands — where a value sits and what that means in plain words, never a diagnosis. The coach proposes concrete if-then plans you accept or decline, and suppresses proactive check-ins when there's nothing genuine to say. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so AI requests stay on your network.
- **Clinician-ready output.** A doctor-report PDF rendered on your server, a read-only HL7 FHIR R4 API, and scoped, time-limited share links you revoke after the visit.
- **Private by construction.** AES-256-GCM encryption at rest for notes and other free text, documents, questionnaire answers, AI-written text and credentials, with zero-downtime key rotation. Measurement values, lab values, medication names and mood scores are stored unencrypted in the database, so the database volume and its backups need the same protection as the host. Passkey login, server-side sessions, strict CSP, and no third-party tracking anywhere.
- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, Polish, Korean, and Dutch end to end.
- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, Polish, Korean, Dutch, and Brazilian Portuguese end to end.
The full feature tour, integration guides, and API reference live at [docs.healthlog.dev](https://docs.healthlog.dev).
@@ -109,13 +109,14 @@ Next.js 16 (App Router, React Server Components), TypeScript strict, PostgreSQL
## Documentation
| | |
| ---------------------- | ------------------------------------------------ |
| User and operator docs | [docs.healthlog.dev](https://docs.healthlog.dev) |
| Release history | [CHANGELOG.md](CHANGELOG.md) |
| API contract | [`docs/api/openapi.yaml`](docs/api/openapi.yaml) |
| Security policy | [SECURITY.md](SECURITY.md) |
| Contributing | [CONTRIBUTING.md](CONTRIBUTING.md) |
| | |
| ---------------------- | ------------------------------------------------------------ |
| User and operator docs | [docs.healthlog.dev](https://docs.healthlog.dev) |
| Release history | [CHANGELOG.md](CHANGELOG.md) |
| API contract | [`docs/api/openapi.yaml`](docs/api/openapi.yaml) |
| Security policy | [SECURITY.md](SECURITY.md) |
| Contributing | [CONTRIBUTING.md](CONTRIBUTING.md) |
| Translating | [CONTRIBUTING.md#translations](CONTRIBUTING.md#translations) |
## Status
+119 -10
View File
@@ -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
}
}
+6
View File
@@ -508,6 +508,11 @@ services:
# support report and Admin > System status say so. On a busy instance,
# raise it with APP_MEMORY_LIMIT in .env; do not lower it.
mem_limit: "${APP_MEMORY_LIMIT:-1536m}"
# On `docker stop` (a redeploy) the app stops taking requests, lets the
# requests in flight finish and gives running jobs up to 30 s to end
# before it exits. Docker's default of 10 s cut that short and left
# jobs marked active until their expiry, holding exclusive queues.
stop_grace_period: 45s
healthcheck:
test:
[
@@ -564,6 +569,7 @@ services:
# max-size: "10m"
# max-file: "3"
# mem_limit: "${WORKER_MEMORY_LIMIT:-1536m}"
# stop_grace_period: 45s
db:
# Pinned to the concrete minor instead of the floating `16-alpine`
+6
View File
@@ -59,6 +59,12 @@ notes, and machine-readable specs that ship alongside the source:
- [`contributing/ui-standards.md`](./contributing/ui-standards.md) — the
binding UI standards: card anatomy, spacing, text colour, tokens and
which primitive to use where. Read before touching any surface.
- [`contributing/benchmarks.md`](./contributing/benchmarks.md) — the
large-account benchmark (latency and statements per request) and the
statement budgets on the hot routes.
- [`contributing/testing.md`](./contributing/testing.md) — which suite a
test belongs in, the route test kit, the integration database, sharding,
property and mutation tests, and the flaky-test quarantine.
- [`ui-guidelines.md`](./ui-guidelines.md) — UI / a11y house style; where
it disagrees with the UI standards, the standards win.
+2210 -743
View File
File diff suppressed because it is too large. Load diff
+119
View File
@@ -0,0 +1,119 @@
# Benchmarks
Two tools measure the server's read and write paths. Use them before and after any change to a hot path, and put both numbers in the PR.
- **`scripts/bench/run.ts`** measures latency (p50, p95, max) and SQL statements per request against a seeded account of about 1.26 million measurement rows, through the production build. It is a local tool, not a CI gate: latency depends on the machine.
- **`tests/integration/query-budget-hot-routes.test.ts`** holds a statement budget per hot route on a small fixture. Statement counts are exact on a fixed fixture, so this one is a gate: a change that adds a query per row, or a second read of something already loaded, fails it.
## The large-account benchmark
### 1. A throwaway Postgres with pg_stat_statements
Never point the seed or the benchmark at a database that holds real data. They write under a synthetic user, and the seed's `--reset` deletes that user with everything it owns.
```sh
docker run -d --name hl-bench-db --shm-size=256m \
-e POSTGRES_USER=bench -e POSTGRES_PASSWORD=bench -e POSTGRES_DB=healthlog_bench \
-p 55432:5432 postgres:16.15-alpine \
postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=top
```
The image and the two settings are the ones `docker-compose.yml` runs in production.
### 2. Environment, migrations, seed
```sh
export DATABASE_URL="postgresql://bench:bench@127.0.0.1:55432/healthlog_bench?schema=public"
export ENCRYPTION_KEYS='{"v1":"<64 hex>"}' ENCRYPTION_ACTIVE_KEY_ID=v1
export API_TOKEN_HMAC_KEY="<64 hex>"
pnpm db:migrate:deploy
pnpm dlx tsx scripts/bench/seed-large-account.ts --reset --anchor 2026-10-22
```
Any throwaway key values work, as long as the seed, the benchmark and the server see the same ones.
The seed writes ten years for one user: Apple Health dense rows (heart rate about every ten minutes, HRV, SpO2, respiratory rate, gait, audio), Withings blood pressure, pulse and scale, a weekly manual weight, three years of Oura resting heart rate and RMSSD, 90 days of Nightscout CGM, `stats:` and per-sample cumulative rows, nightly sleep stages, about 3,000 workouts, three years of mood and of three medications with their intake events. It then folds the rollup tier the way the boot backfill does and runs `ANALYZE`. It takes about 80 seconds on the Mac mini.
The seed is deterministic for a given `--anchor`: values and timestamps are functions of the row index and that day. Without `--anchor` it anchors on today, which is what you want when the benchmark should see recent data.
### 3. A production build
```sh
pnpm build
```
### 4. Run
```sh
pnpm dlx tsx scripts/bench/run.ts --out scripts/bench/results/<label>
```
The script boots the build once with every role, so the worker creates its queues as it does in production, drops whatever that boot scheduled, and then runs the measurement against a web-only process (`HEALTHLOG_PROCESS_TYPE=web`) under the production heap ceiling (`--max-old-space-size=792`, the value Node derives in the 1.5 GB production container). Without the worker, its polling does not land in every statement count. It prints the server's idle statement rate so the remaining background is visible.
For each route it sends three warm-up requests, then twenty measured ones in each of two modes, one request at a time:
- **cold**: before every request a one-row ingest marked as a manual sync evicts the user's caches, so the request rebuilds what it serves. This is a reader's first look after a sync.
- **warm**: back to back, what a cache hit costs.
The batch ingest posts 5,000 fresh heart-rate rows as ten requests of 500, the route's per-request limit, and removes them again at the end.
Statements are the change in `sum(calls)` of `pg_stat_statements` over the database, read before and after each request, once no statement is in flight and the counter holds still. Fire-and-forget writes a request starts are therefore counted with it.
Options: `--iterations`, `--warmup`, `--user`, `--heap-mb`, `--port`, and `--base-url` to measure a server you started yourself.
The output is `<label>.json` (every number, plus the commit, Node and Postgres versions and the row count) and `<label>.md` (the table). Commit the pair when a change is meant to move a number, so the PR shows before and after.
### Reading the numbers
Run on a quiet machine; a parallel build or test run moves latency by tens of percent and leaves the statement counts alone. Compare statement counts across machines, latency only across runs on the same machine. A local number is only valid under the production heap ceiling, which the script sets.
## Boot and idle-memory profile
`scripts/bench/boot-profile.ts` profiles a production start the way production runs it: `next start` on the build, every role (web and worker), the production heap ceiling (`--max-old-space-size=792`, the heap Node derives in the 1.5 GB container), against the seeded benchmark database.
```sh
pnpm dlx tsx scripts/bench/boot-profile.ts --idle-seconds 300 --out scripts/bench/results/<label>-boot
```
It records:
- the time to the first answer from `/api/health` and to the worker's `startup` line;
- every `event_loop_stall` the process logs, with the uptime, startup phase, boot step and work in flight it names;
- every `runtime.heap.sampled` line, and every full collection from `--trace-gc` (used heap before, live heap after);
- a CPU profile of the start: the main thread's busy stretches over 50 ms without an idle sample, each with its self time per source module, resolved through the chunk source maps (`profile-analyze.ts`);
- one heap snapshot after the idle wait, taken with `--heapsnapshot-signal` (after a full collection, so it is the live heap), summarised by node type, constructor, largest strings and the largest retained subtrees with a retaining path, and the number of parsed locale catalogs.
The worker runs for the whole idle wait, and its boot passes fold and consolidate the seeded account (old dense readings into their `stats:` rows), roughly halving its row count. Reseed before the next request benchmark, or its numbers describe a different account.
The raw `.cpuprofile` and `.heapsnapshot` go to a temporary directory and are deleted after the summary unless `--keep` is given. Only run it against the benchmark database: a snapshot holds whatever the process decrypted. `--no-snapshot` and `--no-cpu-prof` skip either part. The profiler itself costs memory, so read RSS from this run as an upper bound.
### What the profile found (v1.44 runtime, October 2026)
On the seeded account, idle, with the worker running and no requests (`results/v1.45.0-before-boot.md`):
- **The boot stall is module evaluation.** The longest busy stretch of the main thread was 753 ms, 0.7 s into the start: the instrumentation hook importing the worker, whose five registrars pulled practically all of `src/lib` into one synchronous evaluation (`register-maintenance.ts` alone 280 ms inclusive), followed by the first `/api/health` request compiling its route. The event-loop monitor started after the key and time-zone checks, and a 250 ms tick measures a block by how late the next tick fires, so it reported less than the block or nothing. The beta's 718 ms and 671 ms stalls are this block on a slower machine.
- **The live heap is about 165 MB, not 400.** Full collections brought the heap back to 162 to 172 MB every 15 to 20 s while idle; the used heap between them reached 190 MB. What a heap line shows above the live heap is garbage awaiting the next full collection, plus, on a server with users, the server caches (13 caches of up to 2,000 entries with stale windows of up to an hour). The beta's 400 MB plateau is therefore not a baseline the process cannot shed; it is what the heap grows to under a large limit before a collection, plus caches.
- **What the live heap holds.** Of 172 MB: the query compiler about 50 MB (the engine object 34 MB, its WebAssembly base64 source twice at 4.4 MB, its chunk source twice at 5.6 MB); the locale catalogs about 33 MB (32 parsed catalog objects, eight locales in each of four server bundles, 14 MB, plus the eight-locale chunk source twice at 9.7 MB); module source strings over 50 KB about 44 MB in total (the catalog chunks included); compiled code 19 MB.
### What changed (v1.45.0)
- The event-loop monitor starts first in `register()`, and a stall line names the boot step, startup phase and work in flight (`src/lib/boot/boot-steps.ts`, one `boot.steps` line per boot with each step's duration).
- The worker's registrars are imported one at a time after `boss.start()`, with a turn of the event loop between them.
- The server reads a locale catalog from `messages/<locale>.json` on first use and keeps one parsed copy per process (`src/lib/i18n/server-catalogs.ts`); the seven matchers that index every locale build their index on first use.
Result on the same machine (`results/v1.45.0-boot.md`): the worker graph loads in stretches of at most 246 ms instead of one of 753 ms (the longest application stretch left is the query compiler's start, 336 ms, unchanged); the live heap after five idle minutes went from 162 to 172 MB to 142 to 153 MB; two parsed catalogs (1.6 MB) instead of 32 (14.1 MB), and no catalog chunk sources.
## Statement budgets in the integration suite
`tests/integration/query-budget-hot-routes.test.ts` seeds a small account (four months of the main types with overlapping sources, sleep, glucose, a medication, workouts and mood), and counts the statements of one request per hot route on a cache miss: `/api/auth/me`, the dashboard summary and snapshot, the pulse series over 30, 365 and 3650 days, the day view, insights health status, correlations, and a 500-row batch ingest.
The counter (`tests/integration/query-counter.ts`) wraps `pg`'s `Client.prototype.query` on the copy of `pg` the Prisma adapter loads, and settles background tasks before it closes the window.
Each budget is the measured count. When a change lowers one, lower the budget in the same change. When a change has to raise one, raise it with the reason in the PR. A failure prints every statement the request sent, grouped by text, so an added loop shows up as one statement with a count next to it.
Run it like any integration file:
```sh
pnpm test:integration tests/integration/query-budget-hot-routes.test.ts
```
+225
View File
@@ -0,0 +1,225 @@
# Testing
How the suites are organised, which one a new test belongs in, and the tools
that keep them fast and honest. Commands are in `CONTRIBUTING.md`; this page
is about choosing and writing tests.
## Which suite
| What you are testing | Where | Runs with |
| --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | ----------------------- |
| Pure logic: statistics, time zones, cadence, parsers, unit conversion, formatting | `src/**/__tests__/*.test.ts` (unit) | `pnpm test` |
| Laws of that logic over random inputs | `*.property.test.ts` next to the unit tests | `pnpm test` |
| What a component renders for a given state | `src/**/__tests__/*.test.tsx` (unit) | `pnpm test` |
| A structural rule about the tree (a guard) | `src/__tests__/` with a stated floor | `pnpm test` |
| A route: auth, scope, validation, what it stores and what it answers | `tests/integration/routes/` with the kit | `pnpm test:integration` |
| Anything whose truth lives in Postgres: constraints, `where` semantics, transactions, locks, cascades, time zones in SQL, backups, jobs | `tests/integration/` | `pnpm test:integration` |
| A journey through the real UI | `e2e/` | `pnpm e2e` |
The rule behind the table: put a behavioural test at the level where the
risky collaborator is real. For a route that collaborator is Postgres and the
API kit (`apiHandler`, `requireAuth`, the envelope), so a route test runs
against both. A unit test that replaces Prisma with a mock and then asserts on
the calls it received checks the query text, not the behaviour; it passes when
the query is wrong and fails when it is rewritten correctly. Do not write new
ones: `src/__tests__/test-realism-ratchet.test.ts` counts route tests that mock
`@/lib/db` wholesale or stub `apiHandler`, and `as never` casts in tests, and
those numbers may only fall. Mock at the process boundary you do not own instead: provider HTTP
(`safeFetch`), APNs, SMTP, the model.
## The route test kit
`tests/kit/` holds two modules for tests under `tests/integration/`.
`factories.ts` writes typed fixtures through the application's Prisma client
and returns the stored rows: `makeUser`, `makeAdmin`, `makeSession`,
`makeToken`, `makeMeasurement`, `makeMedication`, `makeWorkout`, `makeGrant`.
Overrides are the Prisma create input of that model, so the compiler checks
every fixture field; there is no reason for `as never` in a kit test.
Sessions and tokens are minted by the server's own `createSession` and
`issueApiToken`, so a fixture cannot drift into a shape authentication no
longer accepts.
`call-route.ts` exports `callRoute(handler, options)`, which builds a real
`NextRequest`, fills the `next/headers` stand-in from `auth`, calls the
exported handler of a `route.ts` (and with it the real `apiHandler`), and
returns `{ status, data, error, meta, body, headers }`.
```ts
import { GET } from "@/app/api/tokens/route";
import { DELETE } from "@/app/api/tokens/[id]/route";
import { callRoute } from "../../kit/call-route";
import { makeSession, makeToken, makeUser } from "../../kit/factories";
import { getPrismaClient, truncateAllTables } from "../setup";
beforeEach(() => truncateAllTables(getPrismaClient()));
it("revokes the caller's token and the token stops authenticating", async () => {
const owner = await makeUser();
const phone = await makeToken(owner);
const res = await callRoute<{ revoked: boolean }>(DELETE, {
method: "DELETE",
route: "/api/tokens/{id}",
params: { id: phone.tokenId },
auth: await makeSession(owner),
});
expect(res.status).toBe(200);
const row = await getPrismaClient().apiToken.findUniqueOrThrow({
where: { id: phone.tokenId },
});
expect(row.revoked).toBe(true);
const after = await callRoute(GET, {
method: "GET",
route: "/api/tokens",
auth: phone,
});
expect(after.status).toBe(401);
});
```
`auth` is a `SessionFixture`, a `BearerFixture`, or `anonymous`. `route` is
the path as the OpenAPI registry names it, with `{…}` segments filled from
`params`.
Every call is also a contract check. The status must be one the registry
(`src/lib/openapi/routes/`) declares for that path and method, a JSON body
must parse with the registered schema, and a key the schema does not declare
fails the call: a client generated from the contract cannot see that field, so
nobody promised to keep it. There is no generic answer the kit accepts on an
operation's behalf: the scope gate's 403 `auth.scope.insufficient` is declared
on every operation that passes the gate (`withScopeRefusal` in
`src/lib/openapi/routes/shared.ts`), so it is checked like any other status. A
test that calls an unregistered route on purpose passes
`contract: { skip: "<reason>" }`.
`tests/integration/routes/kit-contract.test.ts` proves the check can fail.
Assert what a person or a client would notice: the stored row, the response,
the next request's answer. Not the calls a mock received.
The kit runs the handler, not the edge. Security headers, the public-path
allowlist and demo mode live in `src/proxy.ts` and are tested there.
### The settings round trip
`tests/integration/settings-round-trip.test.ts` holds one table with an entry
for every `User` column the backup classifies as `SETTING`
(`USER_COLUMN_BACKUP_CLASS` in `src/lib/export/backup-plan.ts`). For each one
it sets a non-default value through the API, reads it back through the API and
through `/api/auth/me` where that publishes it, exports a backup, restores it
into a fresh account and reads it back there. A new setting column without a
table entry fails the file. A column with no write route says why in its
entry.
## The integration database
Global setup boots one Postgres container and migrates one template database.
Each Vitest worker clones it on its first file (`CREATE DATABASE … TEMPLATE`,
see `tests/integration/worker-database.ts`), so files run in parallel without
seeing each other's rows, advisory locks or queue schema. The worker count
follows the host (half the cores, at most eight) unless
`HEALTHLOG_INTEGRATION_WORKERS` pins it; CI uses three. To reproduce a CI runner's
contention on a larger machine, cap the database container with
`HEALTHLOG_INTEGRATION_PG_CPUS=2`.
`truncateAllTables` empties every table the migrations created and puts back
the rows they seed (the mood-tag and symptom catalogues), so a test starts
from what a fresh install holds. The list is the catalogue, read from the
template, not something to maintain. `START_EMPTY_TABLES` names the one
seeded table a test starts without, and why. Schemas created at runtime
(pg-boss) are dropped when the next file starts in the worker.
`next/headers` and the boot compatibility probe are mocked for every file in
`environment-setup.ts`. A file needs its own `vi.mock("next/headers")` only
when it wants different behaviour.
## Speed and sharding
CI splits both suites by measured duration, not by file count: the sequencer
in `tests/durations/sequencer.ts` reads `tests/durations/unit.json` and
`tests/durations/integration.json` and deals files out longest first to the
shard with the least work. A file without a measurement counts as the median.
Regenerate a timing file from Vitest JSON reports when the shards drift apart:
```sh
pnpm exec vitest run --reporter=default --reporter=json --outputFile.json=unit.json
node scripts/test-durations.mjs unit unit.json
```
The nightly workflow (`.github/workflows/test-nightly.yml`) uploads a file
regenerated from CI timings, which is the better source.
The unit suite is three projects, decided per file from its source
(`tests/unit-projects.ts`): `guards` and `unit` share a module graph within a
worker, `isolated` gives each file a fresh one. A file lands in `isolated` when
it mocks, spies, stubs env or globals, fakes timers or resets modules. If a
file needs isolation for a reason the classifier cannot see, put a
`// vitest-isolate` comment in it.
## Property tests
`fast-check` properties live in `*.property.test.ts` files and take their
parameters from `tests/property/fast-check.ts`. Pull requests run every
property with a fixed seed, so a run is reproducible on any machine. The
nightly run uses a fresh `FC_SEED` and multiplies every property's own case
count by `FC_RUNS_FACTOR`; a failure prints the seed and the shrunk
counterexample, and `FC_SEED=<seed> pnpm test <file>` replays it. A property
whose cases are expensive states a small count and passes
`propertyTimeout(baseMs)` as its test timeout, which grows by the same factor.
Write a property when the code has a law: an inverse (encrypt and decrypt, a
unit and its inverse, a wall clock and its instant), an invariant (a rate
between 0 and 100, a day that contains its own instants), or an oracle small
enough to be obviously right (a day-by-day walk of a weekly schedule). Do not
reimplement the function under test in the property.
## Mutation testing
Stryker runs on the core libraries (`stryker.config.mjs`) nightly and on
demand (`.github/workflows/mutation.yml`), not on pull requests. Each module's
score is checked against a floor in `stryker.floors.json` by
`node scripts/mutation-floors.mjs`. A floor sits just below the last measured
score; raise it when a change raises the score, never lower it to make a run
pass. The Vitest runner is patched for Vitest 5's test names
(`patches/@stryker-mutator__vitest-runner@10.0.0.patch`); without it every
test inside a `describe` block is filtered out of the mutant runs and almost
every mutant survives. Locally:
```sh
pnpm exec stryker run
node scripts/mutation-floors.mjs
```
## Flaky tests
Vitest runs without retries, and stays that way: a retry that turns red into
green hides the flake it was meant to report. e2e keeps two retries with
`failOnFlakyTests`, so a test that needed one still fails the run.
A flaky unit or integration test may be taken out of the run only through
`tests/quarantine/quarantine.json`:
```json
{
"file": "tests/integration/example.test.ts",
"test": "outer describe > the test name",
"issue": "#1234",
"reason": "fails about one run in twenty when the queue worker is slow",
"added": "2026-10-22",
"expires": "2026-11-05"
}
```
The test is reported as skipped with its issue while the entry is live. An
entry expires at most 14 days after it was added; after its expiry date the
test runs again, and `tests/quarantine/quarantine-guard.test.ts` fails until
the entry is removed. The guard also fails on an entry that names a file or a
test that does not exist. Fix the flake, or delete the test if it no longer
earns its place; do not extend the date.
Order dependence is looked for on purpose: the nightly workflow runs both
suites with `--sequence.shuffle` and prints the seed. Replay a failure with
`--sequence.shuffle --sequence.seed=<seed>`.
+1
View File
@@ -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) |
+26
View File
@@ -109,6 +109,32 @@ that for you, and each boot retries). Anything else is a session you can
judge: a stuck client may be ended with `SELECT pg_terminate_backend(<pid>)`.
You never need to run `migrate resolve` by hand for this case.
### v1.45.0 notes
A single-container install (`HEALTHLOG_PROCESS_TYPE=all`, the shipped
compose file) needs no action: the sequence above applies the one migration
and the job-queue upgrade on the first boot.
- Migration `0471` adds one nullable column to `healthkit_type_syncs`. It is
additive and rewrites no rows.
- The job queue library moves its own schema from version 45 to 46 (new
columns on its queue table, four indexes built concurrently afterwards).
Only a worker process runs that upgrade; a web-only process opens its
send-only connection without migrating and refuses a schema older than
its own (`pg-boss database requires migrations`), so it cannot queue
anything until the upgrade has run. A web/worker split therefore deploys
in this order: stop both roles, start the new **worker** and wait until its
startup log line carries `"reminder_worker": "started"`, then start the new
web containers.
The other order leaves the web containers without a queue (imports and
document reads answer `503 Background worker is not running`) until they
are restarted after the worker. An old web container left running beside
the upgraded schema loses its queue the next time it reconnects.
- The shipped compose file gives the app `stop_grace_period: 45s`, so a
redeploy lets running jobs finish instead of cutting them off after
Docker's default ten seconds. A forked or older compose file should add the
same line to the app and worker services.
### v1.44.0 notes
Migration `0430` drops the `insights_cached_text` column from `users`. A
+3 -2
View File
@@ -165,8 +165,9 @@ singleton key with the run you start, so the two never overlap. The admin
card keeps showing only the runs you started.
The check also measures how far the table file has grown past what its live
rows need (`maintenance_bloat_factor` on the wide event, `bloat_factor` in the
job result). Past 3 it adds a warning to the event: a vacuum makes the space
rows need (`maintenance_bloat_factor` on the wide event, as a factor such as
1.3; `bloat_percent` in the job result, as whole percent such as 130, because a
job fact is a whole number). Past 3 it adds a warning to the event: a vacuum makes the space
reusable but does not shrink the file, and only `VACUUM FULL` does (see
below).
+47 -1
View File
@@ -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 }) => {
+560
View File
@@ -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 },
]);
});
});
+2 -4
View File
@@ -514,10 +514,8 @@ test.describe("Coach page frame", () => {
expect(await stripBox(strip(page))).toEqual(stripOpen);
await expect(expand(page)).toBeFocused();
await expect(expand(page)).toHaveAttribute("aria-expanded", "false");
await expect(expand(page)).toHaveAttribute(
"aria-label",
"Show conversations",
);
// Named by its visible label; the action is the tooltip (WCAG 2.5.3).
await expect(expand(page)).toHaveAttribute("title", "Show conversations");
await expect(
panel(page).locator('[data-slot="coach-history-select"]'),
).toHaveCount(0);
+6 -2
View File
@@ -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 {
+3 -1
View File
@@ -192,7 +192,9 @@ test.describe("the day view", () => {
await expect(expand).toBeFocused();
await expect(expand).toHaveAttribute("aria-expanded", "false");
await expect(expand).toHaveAttribute("aria-controls", "day-docked-panel");
expect(await expand.getAttribute("aria-label")).toMatch(/^Show /);
// Named by its visible date (WCAG 2.5.3); the action is the tooltip.
expect(await expand.getAttribute("aria-label")).toBeNull();
expect(await expand.getAttribute("title")).toMatch(/^Show /);
expect((await rail.boundingBox())!.width).toBeLessThanOrEqual(40);
// Another page: the edge is still there and brings the same day back,
+173
View File
@@ -0,0 +1,173 @@
import AxeBuilder from "@axe-core/playwright";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* One full check-in per instrument through the shared wizard: the stem is
* on every step, the answers form one radio group in the source order, the
* number keys answer, the review lists every answer and changes one, and
* the result carries score, band, meaning and the next step. The PHQ-9 run
* answers item 9 above zero, so its result must lead with the crisis card.
*
* The total on the result is the server's; this journey adds the chosen
* option values itself and compares, which is the scoring contract seen
* from the outside (`instruments.test.ts` pins it from the inside). Each
* screen is scanned with axe on the way.
*
* Anchored on data attributes only, so the journey reads the same at every
* width and in every language.
*/
type Case = {
id: "PHQ9" | "GAD7" | "WHO5" | "SCI";
items: number;
options: number;
multiplier: number;
followUp: boolean;
};
const CASES: readonly Case[] = [
{ id: "PHQ9", items: 9, options: 4, multiplier: 1, followUp: true },
{ id: "GAD7", items: 7, options: 4, multiplier: 1, followUp: false },
{ id: "WHO5", items: 5, options: 6, multiplier: 4, followUp: false },
{ id: "SCI", items: 8, options: 5, multiplier: 1, followUp: false },
];
async function expectNoSeriousViolations(page: Page, label: string) {
// The pointer rests where the last tap landed; a hover tint under it is
// not what the screen shows at rest.
await page.mouse.move(0, 0);
await page.waitForTimeout(300);
const results = await new AxeBuilder({ page })
.include(
'[data-slot="mental-health-check-in"], [data-slot="mental-health-result"]',
)
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
const blocking = results.violations
.filter((v) => v.impact === "serious" || v.impact === "critical")
.map(
(v) =>
`${v.id}: ${v.nodes.map((n) => `${n.html} ${n.failureSummary ?? ""}`).join(" | ")}`,
);
expect(blocking, `axe on ${label}`).toEqual([]);
}
test.describe("mental-health check-in, every instrument", () => {
test.use({ storageState: STORAGE_STATE_PATH });
for (const c of CASES) {
test(`${c.id}: steps, review, result`, async ({ page }) => {
test.setTimeout(90_000);
await page.goto("/mental-wellbeing");
const card = page.locator(
`[data-slot="instrument-card"][data-instrument="${c.id}"]`,
);
await card.locator('[data-slot="instrument-card-start"]').click();
const wizard = page.locator(
`[data-slot="mental-health-check-in"][data-instrument="${c.id}"]`,
);
await expect(wizard).toBeVisible();
const values: number[] = [];
for (let step = 1; step <= c.items; step++) {
const current = wizard.locator(
`[data-slot="check-in-step"][data-step="${step}"]`,
);
await expect(current).toBeVisible();
await expect(
current.locator('[data-slot="check-in-stem"]'),
).toHaveCount(1);
const group = current.locator('[role="radiogroup"]');
await expect(group.locator('[role="radio"]')).toHaveCount(c.options);
if (step === 1) await expectNoSeriousViolations(page, `${c.id} step 1`);
// PHQ-9 item 9 answered above zero: the option in the last position
// is "nearly every day" (value 3).
const position =
c.id === "PHQ9" && step === 9 ? c.options : (step % c.options) + 1;
const answered = group.locator('[role="radio"]').nth(position - 1);
values.push(Number(await answered.getAttribute("data-value")));
if (step % 2 === 0) {
// Keyboard: the number key picks the answer in that position.
await group.locator('[role="radio"]').first().focus();
await page.keyboard.press(String(position));
} else {
await group
.locator('[role="radio"]')
.nth(position - 1)
.click();
}
}
if (c.followUp) {
const followUp = wizard.locator(
'[data-slot="check-in-step"][data-functional]',
);
await expect(followUp).toBeVisible();
await followUp.locator('[role="radio"]').nth(1).click();
await wizard.locator('[data-slot="check-in-next"]').click();
}
const review = wizard.locator('[data-slot="check-in-review"]');
await expect(review).toBeVisible();
await expect(
review.locator('[data-slot="check-in-review-row"]'),
).toHaveCount(c.items + (c.followUp ? 1 : 0));
await expectNoSeriousViolations(page, `${c.id} review`);
// Change the second answer from the review; the wizard returns there.
await review.locator('[data-review-edit="2"]').click();
const second = wizard.locator(
'[data-slot="check-in-step"][data-step="2"]',
);
await expect(second).toBeVisible();
const other =
values[1] ===
Number(
await second
.locator('[role="radio"]')
.first()
.getAttribute("data-value"),
)
? 1
: 0;
await second.locator('[role="radio"]').nth(other).click();
await expect(review).toBeVisible();
values[1] = Number(
await review
.locator('[data-slot="check-in-review-row"][data-step="2"]')
.getAttribute("data-value"),
);
await wizard.locator('[data-slot="check-in-submit"]').click();
const result = page.locator(
`[data-slot="mental-health-result"][data-instrument="${c.id}"]`,
);
await expect(result).toBeVisible();
const total = values.reduce((sum, v) => sum + v, 0) * c.multiplier;
await expect(result.locator('[data-slot="result-score"]')).toContainText(
String(total),
);
await expect(result.locator('[data-slot="result-band"]')).toBeVisible();
await expect(
result.locator('[data-slot="result-meaning"]'),
).toBeVisible();
await expect(
result.locator(
'[data-slot="result-follow-up-hint"], [data-slot="result-next-step"]',
),
).toHaveCount(1);
if (c.id === "PHQ9") {
await expect(
result.locator('[data-slot="mental-health-crisis-card"]'),
).toBeVisible();
}
await expectNoSeriousViolations(page, `${c.id} result`);
});
}
});
+5 -1
View File
@@ -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.
+1
View File
@@ -58,6 +58,7 @@ const SETTINGS_ROUTES = [
"/settings/api",
"/settings/mcp",
"/settings/advanced",
"/settings/preview",
"/settings/about",
] as const;
+11 -1
View File
@@ -33,7 +33,17 @@ import {
type SetupScreen,
} from "./setup-flow-helpers";
const LOCALES = ["en", "de", "es", "fr", "it", "pl", "ko", "nl"] as const;
const LOCALES = [
"en",
"de",
"es",
"fr",
"it",
"pl",
"ko",
"nl",
"pt-BR",
] as const;
const VIEWPORTS = [
{ label: "desktop 1280", width: 1280, height: 800 },
+62 -4
View File
@@ -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);
+138
View File
@@ -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();
}
}
+11
View File
@@ -332,6 +332,11 @@ test("value lines: quarterly means, gaps kept, short ones bridged, as many as th
);
expect(dots).toHaveLength(15);
await page.keyboard.press("Escape");
// The menu closes on an exit animation and only then hands focus back to
// its trigger, which scrolls the page to the toolbar. Measured before
// that, the scroll to the last row below was undone a moment later.
await expect(options).toHaveCount(0);
await expect(trigger).toBeFocused();
await expect
.poll(() => log.timelineQueries.at(-1)?.get("values")?.split(",").length)
.toBe(15);
@@ -504,6 +509,12 @@ test("a chosen range lives in the URL: reload keeps it, Back returns to it", asy
expect(await chart.boundingBox()).toEqual(before);
await expect(desktop.locator('[data-slot="timeline-range"]')).toHaveCount(0);
expect(page.url()).not.toContain("zoom=range");
// The popover slides and scales in (`slide-in-from-top-2 zoom-in-95`);
// measured mid-animation it sat up to 8 px higher than where it comes to
// rest. Read its place once the entry animation has finished.
await popover.evaluate((el) =>
Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished)),
);
const anchor = await zoom("range").boundingBox();
const box = await popover.boundingBox();
expect(box!.y).toBeGreaterThanOrEqual(anchor!.y + anchor!.height);
+219
View File
@@ -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;
+4
View File
@@ -21,6 +21,8 @@
* toLocaleString().
* - `no-raw-card-shell` — a card surface is a <Card> or a shell
* primitive, not bg-card + border + rounded-xl.
* - `import-boundaries` — src/lib never imports components or app, and
* a "use client" file imports no server module.
*/
"use strict";
@@ -36,6 +38,7 @@ const noUtcDayKey = require("./no-utc-day-key.js");
const noPlanningTags = require("./no-planning-tags.js");
const noUnlocalisedDecimal = require("./no-unlocalised-decimal.js");
const noRawCardShell = require("./no-raw-card-shell.js");
const importBoundaries = require("./import-boundaries.js");
module.exports = {
rules: {
@@ -55,5 +58,6 @@ module.exports = {
"no-planning-tags": noPlanningTags,
"no-unlocalised-decimal": noUnlocalisedDecimal,
"no-raw-card-shell": noRawCardShell,
"import-boundaries": importBoundaries,
},
};
+22
View File
@@ -176,6 +176,27 @@ const eslintConfig = defineConfig([
],
},
],
// Layer direction: src/lib never imports src/components or src/app, and
// a "use client" file makes no value import of the database client,
// crypto, the Prisma client entry, the job queue, pg or next/headers.
// The files below still import upward; each says why it has not moved.
// A listed file that stops importing upward fails until it is removed,
// so the map only shrinks. See the rule header.
"healthlog/import-boundaries": [
"error",
{
allow: {
"src/lib/insights/metric-catalog.ts":
"reads SUB_PAGE_TABS, which is declared inside the client tab strip component together with its labels and order",
"src/lib/insights/target-adjust-context.tsx":
"a React provider that renders TargetEditSheet; it belongs under components but has importers across 21 insight pages",
"src/lib/errors/error-toast.tsx":
"a React toast renderer that renders ErrorIdLine; it belongs under components but is imported by most client surfaces",
"src/lib/module-list-prefs.ts":
"a client hook module that raises the shared outcome toast; it moves with the module list settings surface",
},
},
],
},
},
// v1.28.17 — every recharts-rendering component funnels through the
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+10
View File
@@ -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
View File
@@ -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)}`;
+22
View File
@@ -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
+2094 -1146
View File
File diff suppressed because it is too large. Load diff
+15 -2
View File
@@ -12,8 +12,11 @@ overrides:
# GHSA-55q2-fjhq-7xh7: a crafted document gets past the sanitiser and its
# markup survives into the output. The copy here is jspdf's optional HTML
# renderer, which the doctor-report PDF path reaches.
# A later advisory against 3.4.13 to 3.4.15 raised the floor to 3.4.16.
"dompurify@<3.4.16": "^3.4.16"
# A later advisory against 3.4.13 to 3.4.15 raised the floor to 3.4.16, and
# GHSA-cg2g-xh64-v4q7 (a SAFE_FOR_TEMPLATES bypass) with GHSA-mrv2-3pqr-j5jr
# (an IN_PLACE hook that removes a parent) to 3.4.17. jspdf 4.2.1, the
# newest release, still allows the flagged range.
"dompurify@<3.4.17": "^3.4.17"
# CVE-2026-69153. Next declares an exact `postcss@8.4.31`, so nothing except
# this bound moves it. The build has run on the 8.5 line since the first
# bump; the fix landed in 8.5.23.
@@ -103,6 +106,13 @@ overrides:
"brace-expansion@>=4.0.0 <5.0.12": "^5.0.12"
"undici@>=7.0.0 <7.29.1": "^7.29.1"
# GHSA-587h-7wmj-h5pm (HIGH), GHSA-rq7h-c2jc-7f22, GHSA-r825-w8mm-5pxw,
# GHSA-698v-4r3x-3vcv and GHSA-9jrq-w75r-8gcw: dev-server file serving,
# cross-origin middleware and the bundled launch-editor. Vite arrives only
# under vitest, never in the image; vitest asks for ^8, so a floor on the
# same major moves it and nothing else.
"vite@>=8.0.0 <8.1.6": "^8.1.6"
allowBuilds:
"@prisma/engines": true
prisma: true
@@ -131,3 +141,6 @@ auditConfig:
# never runs. No patched release is published yet (latest is 1.4.0);
# remove this entry and raise the floor once 1.4.1 is out.
- GHSA-86w9-cpqp-85rv
patchedDependencies:
"@stryker-mutator/vitest-runner@10.0.0": patches/@stryker-mutator__vitest-runner@10.0.0.patch
@@ -0,0 +1,12 @@
-- #1259: a batch that carries a type with duplicates only no longer counts as
-- an arrival of that type. It stamps this column instead, so the card can
-- still say the phone re-sent the type while `last_received_at` keeps the
-- last batch that brought something new.
--
-- Nullable, no default, no backfill: null reads as "no duplicates-only batch
-- recorded", which is true for every row until the next one.
--
-- Reversibility: dropping the column loses only that stamp; nothing else
-- reads it.
ALTER TABLE "healthkit_type_syncs" ADD COLUMN IF NOT EXISTS "last_seen_duplicate_at" TIMESTAMP(3);
+18 -10
View File
@@ -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)
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "en",
"locales": ["en", "de", "fr", "es", "it", "pl", "ko", "nl", "pt-BR"],
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4.5.5/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.44.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/*`
+1
View File
@@ -28,6 +28,7 @@ typecheck.
- **`repair-intake-anomalies.ts`** — repair historic medication-ledger defects (duplicate dose-slot rows, implausible `taken_at`); dry-run by default, see `docs/ops/intake-repair.md`.
- **`backfill-rollups.ts`**, **`drain-per-sample-cumulative.ts`**, **`backfill-mood-note-column.ts`** — historical data backfills (note: the boot-time `rollup-full-backfill` queue handles new accounts automatically; the CLI is the manual fallback).
- **`seed-demo.ts`** + **`seed-demo-v15.sql`** — populate a demo tenant.
- **`bench/`** — `seed-large-account.ts` (a synthetic ten-year account of about 1.26 million measurement rows) and `run.ts` (latency and SQL statements per request against it); see `docs/contributing/benchmarks.md`. Results land in `bench/results/`.
- **`test-notifications.ts`** — exercise the notification dispatcher + reminder check.
- **`fetch-geolite2.sh`** — download the MaxMind GeoLite2 MMDBs (needs a licence key) before `docker build`.
- **`install-hooks.sh`** — install the local git hooks.
+3
View File
@@ -157,6 +157,9 @@ describe("container dependency installation", () => {
expect(dockerfile).toContain(
"COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./\n" +
"# The dependency patches pnpm-workspace.yaml names; the frozen install\n" +
"# refuses to run without them.\n" +
"COPY patches patches\n" +
"COPY scripts/prepare.mjs scripts/prepare.mjs\n" +
"RUN pnpm install --frozen-lockfile --prod=false",
);
+10 -1
View File
@@ -236,6 +236,7 @@ describe("release container inputs", () => {
const dockerfile = readRepoFile("Dockerfile");
const packageJson = JSON.parse(readRepoFile("package.json")) as {
packageManager: string;
dependencies: Record<string, string>;
};
expect(packageJson.packageManager).toBe("pnpm@11.15.1");
expect(dockerfile).not.toContain("pnpm@latest");
@@ -249,7 +250,15 @@ describe("release container inputs", () => {
expect(dockerfile).toContain(
"ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx",
);
expect(dockerfile).toContain("dotenv@18.0.6");
// The image's npm-installed Prisma CLI, its engines and dotenv are a second
// install of packages the app tree already declares; they move together or
// the image runs a CLI the tests never did. Drift is how the dotenv pin
// here trailed the app's by a patch.
const floor = (name: string) =>
packageJson.dependencies[name]!.replace(/^[\^~]/, "");
expect(dockerfile).toContain(`prisma@${floor("prisma")} `);
expect(dockerfile).toContain(`@prisma/engines@${floor("prisma")} `);
expect(dockerfile).toContain(`dotenv@${floor("dotenv")} `);
expect(dockerfile).toContain(
"ln -sfn /opt/prisma-cli/node_modules/dotenv /app/node_modules/dotenv",
);
+407
View File
@@ -0,0 +1,407 @@
/**
* Boot and idle-memory profile of the production build.
*
* DATABASE_URL=postgresql://… API_TOKEN_HMAC_KEY=… ENCRYPTION_KEYS=… \
* ENCRYPTION_ACTIVE_KEY_ID=… pnpm dlx tsx scripts/bench/boot-profile.ts \
* [--idle-seconds 300] [--heap-mb 792] [--port 3101] \
* [--no-snapshot] [--no-cpu-prof] [--out scripts/bench/results/<label>-boot]
*
* Starts `next start` with every role (web and worker, as production runs it)
* under the production heap ceiling, against the seeded benchmark database
* (see docs/contributing/benchmarks.md; never a database with real data: a
* heap snapshot holds whatever the process decrypted). It records:
*
* - the time to the first healthy `/api/health` and to `[boot] background
* role ready`;
* - every `event_loop_stall` the process logs, with the task it names;
* - every `runtime.heap.sampled` line;
* - with `--cpu-prof` (default on), the busy stretches of the main thread
* over 100 ms during the boot, each with its self time by source module;
* - with a snapshot (default on), one heap snapshot after the idle wait
* (taken with `--heapsnapshot-signal`, so after a full GC: the live heap),
* summarised by node type, constructor, largest strings and the largest
* retained subtrees with a retaining path.
*
* The raw `.cpuprofile` and `.heapsnapshot` are written to a temporary
* directory and deleted after the summary unless `--keep` is given.
*
* The worker's boot passes fold the seeded account while it idles; reseed
* before running `run.ts` again.
*
* Output: `<out>.json` and `<out>.md`.
*/
import { execFileSync, spawn } from "node:child_process";
import {
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { cpus, loadavg, tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { longTasks, selfTimeByModule, summariseHeap } from "./profile-analyze";
function arg(name: string, fallback: string): string {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1]! : fallback;
}
const flag = (name: string) => process.argv.includes(`--${name}`);
const IDLE_SECONDS = Number(arg("idle-seconds", "300"));
const HEAP_MB = Number(arg("heap-mb", "792"));
const PORT = Number(arg("port", "3101"));
const OUT = arg("out", join("scripts", "bench", "results", "latest-boot"));
const SNAPSHOT = !flag("no-snapshot");
const CPU_PROF = !flag("no-cpu-prof");
const KEEP = flag("keep");
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
function find(obj: unknown, key: string): unknown {
if (!obj || typeof obj !== "object") return undefined;
const rec = obj as Record<string, unknown>;
if (key in rec) return rec[key];
for (const v of Object.values(rec)) {
const hit = find(v, key);
if (hit !== undefined) return hit;
}
return undefined;
}
async function main(): Promise<void> {
if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required");
const work = mkdtempSync(join(tmpdir(), "hl-boot-profile-"));
const nodeOptions = [
`--max-old-space-size=${HEAP_MB}`,
...(CPU_PROF ? ["--cpu-prof", `--cpu-prof-dir=${work}`] : []),
...(SNAPSHOT ? ["--heapsnapshot-signal=SIGUSR2"] : []),
].join(" ");
const started = Date.now();
const child = spawn(
process.execPath,
[
// One line per collection; the mark-compacts say how much of the used
// heap was live and how much was garbage waiting for a full GC. Not
// allowed in NODE_OPTIONS, and `next start` serves in this process.
"--trace-gc",
"node_modules/next/dist/bin/next",
"start",
"-p",
String(PORT),
"-H",
"127.0.0.1",
],
{
cwd: process.cwd(),
env: {
...process.env,
NODE_ENV: "production",
PORT: String(PORT),
HOSTNAME: "127.0.0.1",
HEALTHLOG_PROCESS_TYPE: "all",
SESSION_COOKIE_SECURE: "false",
NODE_OPTIONS: nodeOptions,
},
stdio: ["ignore", "pipe", "pipe"],
},
);
// Node writes the snapshot into the process's cwd.
const snapshotDir = process.cwd();
const stalls: Array<Record<string, unknown>> = [];
const heapLines: Array<Record<string, unknown>> = [];
const bootLines: string[] = [];
const markCompacts: Array<{
atMs: number;
beforeMb: number;
afterMb: number;
}> = [];
let readyAtMs: number | null = null;
let snapshotTaken = false;
let buffer = "";
const onLine = (line: string) => {
const gc =
/Mark-Compact(?: \(reduce\))? ([\d.]+) \([\d.]+\) -> ([\d.]+) \(/.exec(
line,
);
if (gc) {
markCompacts.push({
atMs: Date.now() - started,
beforeMb: Number(gc[1]),
afterMb: Number(gc[2]),
});
return;
}
if (line.includes("[boot]")) {
bootLines.push(`${Date.now() - started} ms: ${line.slice(0, 200)}`);
if (line.includes("background role ready"))
readyAtMs = Date.now() - started;
}
if (!line.startsWith("{")) return;
try {
const evt = JSON.parse(line) as Record<string, unknown>;
const task = find(evt, "task_name");
if (task === "startup" && readyAtMs === null) {
readyAtMs = Date.now() - started;
}
if (task === "event_loop_stall") {
// The snapshot itself blocks the process for seconds.
if (snapshotTaken) return;
stalls.push({
atMs: Date.now() - started,
...(find(evt, "result") as object),
});
} else if (task === "runtime.heap.sampled") {
heapLines.push({
atMs: Date.now() - started,
...((find(evt, "meta") as object) ?? {}),
});
} else if (typeof task === "string" && task.startsWith("boot.")) {
bootLines.push(
`${Date.now() - started} ms: ${task} ${JSON.stringify(find(evt, "meta") ?? {})}`.slice(
0,
400,
),
);
}
} catch {
// not a wide event
}
};
child.stdout!.on("data", (d: Buffer) => {
buffer += d.toString();
let nl: number;
while ((nl = buffer.indexOf("\n")) >= 0) {
onLine(buffer.slice(0, nl));
buffer = buffer.slice(nl + 1);
}
});
child.stderr!.on("data", (d: Buffer) => {
for (const line of d.toString().split("\n"))
if (line.includes("[boot]")) onLine(line);
});
let healthyAtMs: number | null = null;
const deadline = Date.now() + 180_000;
while (Date.now() < deadline && healthyAtMs === null) {
if (child.exitCode !== null)
throw new Error(`server exited ${child.exitCode}`);
try {
const res = await fetch(`http://127.0.0.1:${PORT}/api/health`);
if (res.status < 500 || res.status === 503) {
const body = await res.text();
if (res.ok || body.includes("starting"))
healthyAtMs = Date.now() - started;
}
} catch {
// not listening yet
}
await sleep(100);
}
while (readyAtMs === null && Date.now() < deadline) await sleep(200);
console.log(
`health ${healthyAtMs} ms, worker ready ${readyAtMs} ms; idling ${IDLE_SECONDS} s`,
);
await sleep(IDLE_SECONDS * 1000);
let heap: ReturnType<typeof summariseHeap> | null = null;
if (SNAPSHOT) {
const before = new Set(readdirSync(snapshotDir));
snapshotTaken = true;
child.kill("SIGUSR2");
let file = "";
let lastSize = -1;
for (let i = 0; i < 600; i++) {
await sleep(1000);
const f = readdirSync(snapshotDir).find(
(n) => n.endsWith(".heapsnapshot") && !before.has(n),
);
if (!f) continue;
const size = statSync(join(snapshotDir, f)).size;
if (size === lastSize && size > 0) {
file = join(snapshotDir, f);
break;
}
lastSize = size;
}
if (file) {
// A snapshot is finished when the process answers again.
await fetch(`http://127.0.0.1:${PORT}/api/health`).catch(() => undefined);
const moved = join(work, "idle.heapsnapshot");
renameSync(file, moved);
console.log("summarising heap snapshot");
heap = summariseHeap(moved, {
"parsed locale catalogs": ["common", "apiErrors", "doctorReport"],
});
}
}
const exited = new Promise((r) => child.once("exit", r));
child.kill("SIGTERM");
await Promise.race([exited, sleep(60_000)]);
if (child.exitCode === null) child.kill("SIGKILL");
let tasks: ReturnType<typeof longTasks> = [];
let bootSelf: Array<[string, number]> = [];
if (CPU_PROF) {
await sleep(1000);
const profiles = readdirSync(work).filter((n) => n.endsWith(".cpuprofile"));
// The largest profile is the server process (`next start` may run a
// small parent).
const main = profiles
.map((n) => join(work, n))
.sort((a, b) => statSync(b).size - statSync(a).size)[0];
if (main) {
const profile = JSON.parse(readFileSync(main, "utf8"));
tasks = longTasks(profile, 50, 0.5).filter((t) => t.startMs < 120_000);
bootSelf = selfTimeByModule(
profile,
0,
(readyAtMs ?? 30_000) + 5_000,
).slice(0, 40);
}
}
if (!KEEP) rmSync(work, { recursive: true, force: true });
else console.log(`raw files kept in ${work}`);
let commit = "unknown";
try {
commit = execFileSync("git", ["rev-parse", "--short", "HEAD"])
.toString()
.trim();
} catch {
// not a checkout
}
const meta = {
label: OUT.split("/").pop(),
commit,
date: new Date().toISOString(),
node: process.version,
cpus: cpus().length,
loadAverage: loadavg().map((n) => Math.round(n * 10) / 10),
heapMb: HEAP_MB,
idleSeconds: IDLE_SECONDS,
healthyAtMs,
readyAtMs,
stallMaxMs: Math.max(0, ...stalls.map((s) => Number(s.lag_ms ?? 0))),
};
mkdirSync(dirname(OUT), { recursive: true });
// Paths in frame and retainer names are made relative to the checkout,
// before anything shortens them.
const relative = (text: string) => text.split(`${process.cwd()}/`).join("");
if (heap) heap = JSON.parse(relative(JSON.stringify(heap))) as typeof heap;
tasks = JSON.parse(relative(JSON.stringify(tasks))) as typeof tasks;
writeFileSync(
`${OUT}.json`,
relative(
`${JSON.stringify({ meta, bootLines, stalls, heapLines, markCompacts, tasks, bootSelf, heap }, null, 2)}\n`,
),
);
const md: string[] = [
`# Boot profile ${String(meta.label)}`,
"",
`Commit ${commit}, ${meta.date}. Production build, every role, heap ceiling ${HEAP_MB} MB, ${IDLE_SECONDS} s idle. ${meta.cpus} cores, load average ${meta.loadAverage.join(",")} at the end.`,
"",
`- first answer from /api/health: ${healthyAtMs} ms; background role ready: ${readyAtMs} ms`,
`- event-loop stalls logged: ${stalls.length}, max ${meta.stallMaxMs} ms`,
"",
"## Stalls",
"",
...stalls.map((s) => `- ${JSON.stringify(s)}`),
"",
"## Heap samples",
"",
"| at s | used MB | old | large obj | code | external | array buffers | rss |",
"| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
...heapLines.map(
(h) =>
`| ${Math.round(Number(h.atMs) / 1000)} | ${h.heap_used_mb} | ${h.old_space_mb} | ${h.large_object_space_mb} | ${h.code_space_mb} | ${h.external_mb} | ${h.array_buffers_mb} | ${h.rss_mb} |`,
),
"",
"## Full collections (mark-compact)",
"",
`${markCompacts.length} mark-compacts during the run. Used heap before the collection, and what survived it (the live heap):`,
"",
"| at s | before MB | after MB |",
"| ---: | ---: | ---: |",
...markCompacts
.filter(
(m, i, all) =>
i === all.length - 1 || i % Math.ceil(all.length / 15) === 0,
)
.map(
(m) =>
`| ${Math.round(m.atMs / 1000)} | ${m.beforeMb} | ${m.afterMb} |`,
),
"",
"## Main-thread busy stretches over 50 ms without an idle sample, first two minutes",
"",
...tasks.flatMap((t) => [
`### at ${t.startMs} ms, ${t.durationMs} ms`,
"",
"| self time by module | ms |",
"| --- | ---: |",
...t.byModule.slice(0, 10).map(([m, v]) => `| ${m} | ${v} |`),
"",
"| inclusive, application modules | ms |",
"| --- | ---: |",
...t.byEntry.slice(0, 10).map(([m, v]) => `| ${m} | ${v} |`),
"",
]),
"## Self time by module until the worker is ready",
"",
"| module | ms |",
"| --- | ---: |",
...bootSelf.slice(0, 25).map(([m, v]) => `| ${m} | ${v} |`),
"",
];
if (heap) {
md.push(
`## Heap snapshot after ${IDLE_SECONDS} s idle (live heap after a full GC)`,
"",
`${heap.totalMb} MB in ${heap.nodeCount} nodes; module source strings over 50 KB: ${heap.sourceStringsMb} MB.`,
"",
...Object.entries(heap.probes).map(
([k, v]) => `- ${k}: ${v.count} objects, ${v.retainedMb} MB retained`,
),
"",
"| node type | MB |",
"| --- | ---: |",
...heap.byType.map(([k, v]) => `| ${k} | ${v} |`),
"",
"| constructor | MB | count |",
"| --- | ---: | ---: |",
...heap.byConstructor
.slice(0, 20)
.map(([k, v, c]) => `| ${k.replace(/\|/g, "/")} | ${v} | ${c} |`),
"",
"| largest retained subtrees | MB | retaining path |",
"| --- | ---: | --- |",
...heap.topRetainers
.slice(0, 30)
.map(
(r) =>
`| ${r.name.replace(/\|/g, "/")} | ${r.mb} | ${r.path.replace(/\|/g, "/").slice(0, 160)} |`,
),
"",
"| largest strings | MB |",
"| --- | ---: |",
...heap.largestStrings
.slice(0, 15)
.map(([s, t]) => `| ${t.replace(/\|/g, "/").slice(0, 90)} | ${s} |`),
"",
);
}
writeFileSync(`${OUT}.md`, relative(md.join("\n")));
console.log(`wrote ${OUT}.json and ${OUT}.md`);
}
main().catch((err) => {
console.error(err);
process.exitCode = 1;
});
+599
View File
@@ -0,0 +1,599 @@
/**
* Readers for the two files `boot-profile.ts` collects: a V8 CPU profile
* (`.cpuprofile`) and a heap snapshot (`.heapsnapshot`). Both are plain
* JSON; these turn them into the few tables a boot investigation needs, so a
* before/after comparison does not depend on opening DevTools by hand.
*
* Frames are attributed to source modules through the module ids Turbopack
* writes into every server chunk (`"[project]/src/lib/x.ts [app-route] …"`):
* a sample at chunk line N belongs to the last module id that starts at or
* before N.
*/
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
// ---------------------------------------------------------------- modules
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const B64_INDEX = new Map([...B64].map((c, i) => [c, i]));
interface ChunkMap {
sources: string[];
/** Per generated line: [column, source index] pairs, ascending. */
lines: Array<Array<[number, number]>>;
}
const chunkMapCache = new Map<string, ChunkMap | null>();
/** Decode a chunk's source map down to (line, column) → source. */
function chunkMap(url: string): ChunkMap | null {
if (chunkMapCache.has(url)) return chunkMapCache.get(url)!;
let path = url;
try {
if (url.startsWith("file://")) path = fileURLToPath(url);
} catch {
// not a file URL
}
let result: ChunkMap | null = null;
if (existsSync(`${path}.map`)) {
type RawMap = {
sources?: string[];
mappings?: string;
sections?: Array<{ offset: { line: number }; map: RawMap }>;
};
const raw = JSON.parse(readFileSync(`${path}.map`, "utf8")) as RawMap;
const sources: string[] = [];
const lines: Array<Array<[number, number]>> = [];
// An index map (`sections`) holds one ordinary map per offset.
const flat: Array<{ line: number; map: RawMap }> = raw.sections
? raw.sections.map((s) => ({ line: s.offset.line, map: s.map }))
: [{ line: 0, map: raw }];
for (const { line: lineOffset, map } of flat) {
const base = sources.length;
sources.push(...(map.sources ?? []));
let src = 0;
let lineNo = lineOffset;
for (const line of (map.mappings ?? "").split(";")) {
const segs: Array<[number, number]> = lines[lineNo] ?? [];
let col = 0;
for (const seg of line.split(",")) {
if (!seg) continue;
const vals: number[] = [];
let shift = 0;
let value = 0;
for (const ch of seg) {
const d = B64_INDEX.get(ch)!;
value += (d & 31) << shift;
if (d & 32) shift += 5;
else {
const neg = value & 1;
value >>= 1;
vals.push(neg ? -value : value);
value = 0;
shift = 0;
}
}
col += vals[0] ?? 0;
if (vals.length > 1) {
src += vals[1]!;
segs.push([col, base + src]);
}
}
lines[lineNo] = segs;
lineNo++;
}
}
for (const segs of lines) segs?.sort((x, y) => x[0] - y[0]);
result = { sources, lines };
}
chunkMapCache.set(url, result);
return result;
}
function packageOf(path: string): string {
const parts = path.split("node_modules/");
const rest = parts[parts.length - 1]!.split("/");
return `npm:${rest[0]!.startsWith("@") ? `${rest[0]}/${rest[1]}` : rest[0]}`;
}
/** The source module a frame at (url, 0-based line, column) belongs to. */
export function moduleForFrame(url: string, line: number, column = 0): string {
if (!url) return "(native)";
if (url.startsWith("node:")) return url;
if (url.includes("/node_modules/") && !url.includes("/.next/")) {
return packageOf(url);
}
const map = chunkMap(url);
const segs = map?.lines[line];
if (map && segs && segs.length > 0) {
let lo = 0;
let hi = segs.length - 1;
let found = segs[0]![1];
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (segs[mid]![0] <= column) {
found = segs[mid]![1];
lo = mid + 1;
} else hi = mid - 1;
}
const source = (map.sources[found] ?? "")
.replace(/^turbopack:\/\/\/\[project\]\//, "")
.replace(/^(\.\.\/)+/, "");
if (source.includes("node_modules/")) return packageOf(source);
return source || url;
}
return url.replace(/^.*\/\.next\//, ".next/");
}
// ------------------------------------------------------------ CPU profile
interface CpuNode {
id: number;
callFrame: {
functionName: string;
url: string;
lineNumber: number;
columnNumber: number;
};
children?: number[];
}
interface CpuProfile {
nodes: CpuNode[];
startTime: number;
endTime: number;
samples: number[];
timeDeltas: number[];
}
export interface LongTask {
startMs: number;
durationMs: number;
/** Self time by source module, descending. */
byModule: Array<[string, number]>;
/** Self time by function, descending. */
byFunction: Array<[string, number]>;
/** Inclusive time of the outermost application frames. */
byEntry: Array<[string, number]>;
}
const IDLE = new Set(["(idle)", "(root)"]);
/**
* Busy stretches of the main thread: consecutive non-idle samples whose gaps
* stay under `gapMs`, longer than `minMs`. Each comes with its self time by
* module and by function.
*/
export function longTasks(
profile: CpuProfile,
minMs = 100,
gapMs = 5,
): LongTask[] {
const byId = new Map<number, CpuNode>();
const parent = new Map<number, number>();
for (const n of profile.nodes) {
byId.set(n.id, n);
for (const c of n.children ?? []) parent.set(c, n.id);
}
const tasks: LongTask[] = [];
let current: {
start: number;
end: number;
samples: Array<[number, number]>;
} | null = null;
const flush = () => {
if (!current) return;
const dur = (current.end - current.start) / 1000;
if (dur >= minMs) {
const mods = new Map<string, number>();
const fns = new Map<string, number>();
const entries = new Map<string, number>();
for (const [id, dt] of current.samples) {
const n = byId.get(id)!;
const cf = n.callFrame;
const mod = moduleForFrame(cf.url, cf.lineNumber, cf.columnNumber);
mods.set(mod, (mods.get(mod) ?? 0) + dt);
const fn = `${cf.functionName || "(anonymous)"} ${mod}`;
fns.set(fn, (fns.get(fn) ?? 0) + dt);
// Inclusive: every distinct module on the stack gets the sample once.
const seen = new Set<string>();
let cur: number | undefined = id;
while (cur !== undefined) {
const c = byId.get(cur)!.callFrame;
if (c.url) {
const m = moduleForFrame(c.url, c.lineNumber, c.columnNumber);
if (m.startsWith("src/") && !seen.has(m)) {
seen.add(m);
entries.set(m, (entries.get(m) ?? 0) + dt);
}
}
cur = parent.get(cur);
}
}
const top = (m: Map<string, number>) =>
[...m.entries()]
.map(([k, v]) => [k, Math.round(v / 100) / 10] as [string, number])
.sort((a, b) => b[1] - a[1])
.slice(0, 15);
tasks.push({
startMs: Math.round((current.start - profile.startTime) / 1000),
durationMs: Math.round(dur),
byModule: top(mods),
byFunction: top(fns),
byEntry: top(entries),
});
}
current = null;
};
let at = profile.startTime;
for (let i = 0; i < profile.samples.length; i++) {
at += profile.timeDeltas[i] ?? 0;
const id = profile.samples[i]!;
const name = byId.get(id)?.callFrame.functionName ?? "";
const dt = profile.timeDeltas[i + 1] ?? 0;
if (IDLE.has(name)) {
if (current && at - current.end > gapMs * 1000) flush();
continue;
}
if (!current) current = { start: at, end: at, samples: [] };
else if (at - current.end > gapMs * 1000) {
flush();
current = { start: at, end: at, samples: [] };
}
current.end = at + dt;
current.samples.push([id, dt]);
}
flush();
return tasks;
}
/** Self time per module over the whole profile, or a window of it. */
export function selfTimeByModule(
profile: CpuProfile,
fromMs = 0,
toMs = Infinity,
): Array<[string, number]> {
const byId = new Map(profile.nodes.map((n) => [n.id, n]));
const mods = new Map<string, number>();
let at = profile.startTime;
for (let i = 0; i < profile.samples.length; i++) {
at += profile.timeDeltas[i] ?? 0;
const rel = (at - profile.startTime) / 1000;
if (rel < fromMs || rel > toMs) continue;
const n = byId.get(profile.samples[i]!)!;
if (IDLE.has(n.callFrame.functionName)) continue;
const mod = moduleForFrame(
n.callFrame.url,
n.callFrame.lineNumber,
n.callFrame.columnNumber,
);
const dt = profile.timeDeltas[i + 1] ?? 0;
mods.set(mod, (mods.get(mod) ?? 0) + dt);
}
return [...mods.entries()]
.map(([k, v]) => [k, Math.round(v / 100) / 10] as [string, number])
.sort((a, b) => b[1] - a[1]);
}
// ---------------------------------------------------------- heap snapshot
interface HeapSnapshot {
snapshot: {
meta: {
node_fields: string[];
node_types: [string[], ...unknown[]];
edge_fields: string[];
edge_types: [string[], ...unknown[]];
};
node_count: number;
};
nodes: number[];
edges: number[];
strings: string[];
}
export interface HeapSummary {
totalMb: number;
nodeCount: number;
byType: Array<[string, number]>;
byConstructor: Array<[string, number, number]>;
largestStrings: Array<[number, string]>;
/** Retained size of the largest dominator subtrees below the GC roots. */
topRetainers: Array<{ mb: number; name: string; path: string }>;
/** Module source strings (whole chunk scripts) over 50 KB. */
sourceStringsMb: number;
/**
* For each probe, the objects that carry every listed property: how many,
* and their summed retained size (the outermost ones only).
*/
probes: Record<string, { count: number; retainedMb: number }>;
}
/**
* Self size by node type and constructor, the largest strings, and retained
* sizes from the dominator tree (Cooper, Harvey, Kennedy), with a retaining
* path for each of the largest subtrees.
*/
export function summariseHeap(
file: string,
probes: Record<string, string[]> = {},
): HeapSummary {
const snap = JSON.parse(readFileSync(file, "utf8")) as HeapSnapshot;
const nf = snap.snapshot.meta.node_fields;
const ef = snap.snapshot.meta.edge_fields;
const nodeTypes = snap.snapshot.meta.node_types[0];
const edgeTypes = snap.snapshot.meta.edge_types[0];
const NF = nf.length;
const EF = ef.length;
const iType = nf.indexOf("type");
const iName = nf.indexOf("name");
const iSelf = nf.indexOf("self_size");
const iEdges = nf.indexOf("edge_count");
const eType = ef.indexOf("type");
const eName = ef.indexOf("name_or_index");
const eTo = ef.indexOf("to_node");
const weak = edgeTypes.indexOf("weak");
const shortcut = edgeTypes.indexOf("shortcut");
const elementT = edgeTypes.indexOf("element");
const hiddenT = edgeTypes.indexOf("hidden");
const N = snap.nodes.length / NF;
const nodes = snap.nodes;
const edges = snap.edges;
const strings = snap.strings;
const firstEdge = new Uint32Array(N + 1);
for (let i = 0, e = 0; i < N; i++) {
firstEdge[i] = e;
e += nodes[i * NF + iEdges]! * EF;
}
firstEdge[N] = edges.length;
const self = (i: number) => nodes[i * NF + iSelf]!;
const typeOf = (i: number) => nodeTypes[nodes[i * NF + iType]!]!;
const nameOf = (i: number) => strings[nodes[i * NF + iName]!]!;
let total = 0;
const byType = new Map<string, number>();
const byCtor = new Map<string, [number, number]>();
const big: Array<[number, string]> = [];
let sourceStrings = 0;
for (let i = 0; i < N; i++) {
const s = self(i);
total += s;
const t = typeOf(i);
byType.set(t, (byType.get(t) ?? 0) + s);
if (t === "object" || t === "closure" || t === "array" || t === "native") {
const k = `${t}:${nameOf(i)}`.slice(0, 80);
const cur = byCtor.get(k) ?? [0, 0];
cur[0] += s;
cur[1] += 1;
byCtor.set(k, cur);
}
if (
t === "string" ||
t === "concatenated string" ||
t === "sliced string"
) {
if (s > 200_000)
big.push([s, nameOf(i).slice(0, 100).replace(/\s+/g, " ")]);
const nm = nameOf(i);
if (
s > 50_000 &&
/__turbopack_context__|"use strict";|module\.exports/.test(
nm.slice(0, 2000),
)
) {
sourceStrings += s;
}
}
}
// Graph for the dominator tree: strong edges only.
const isStrong = (et: number) => et !== weak && et !== shortcut;
// Post-order DFS from node 0.
const order = new Int32Array(N).fill(-1);
const post: number[] = [];
const stackN: number[] = [0];
const stackE: number[] = [firstEdge[0]!];
order[0] = -2;
while (stackN.length) {
const top = stackN.length - 1;
const n = stackN[top]!;
const e = stackE[top]!;
if (e < firstEdge[n + 1]!) {
stackE[top] = e + EF;
const et = edges[e + eType]!;
if (!isStrong(et)) continue;
const to = edges[e + eTo]! / NF;
if (order[to] === -1) {
order[to] = -2;
stackN.push(to);
stackE.push(firstEdge[to]!);
}
} else {
order[n] = post.length;
post.push(n);
stackN.pop();
stackE.pop();
}
}
const R = post.length;
// Predecessors in post-order numbering (CSR).
const predCount = new Uint32Array(R + 1);
for (let n = 0; n < N; n++) {
if (order[n]! < 0) continue;
for (let e = firstEdge[n]!; e < firstEdge[n + 1]!; e += EF) {
if (!isStrong(edges[e + eType]!)) continue;
const to = edges[e + eTo]! / NF;
if (order[to]! >= 0) predCount[order[to]! + 1]! += 1;
}
}
for (let i = 0; i < R; i++) predCount[i + 1]! += predCount[i]!;
const preds = new Uint32Array(predCount[R]!);
const fill = predCount.slice(0, R);
for (let n = 0; n < N; n++) {
if (order[n]! < 0) continue;
for (let e = firstEdge[n]!; e < firstEdge[n + 1]!; e += EF) {
if (!isStrong(edges[e + eType]!)) continue;
const to = edges[e + eTo]! / NF;
if (order[to]! >= 0) preds[fill[order[to]!]!++] = order[n]!;
}
}
const rootPo = order[0]!;
const idom = new Int32Array(R).fill(-1);
idom[rootPo] = rootPo;
let changed = true;
while (changed) {
changed = false;
for (let b = R - 1; b >= 0; b--) {
if (b === rootPo) continue;
let ni = -1;
for (let p = predCount[b]!; p < predCount[b + 1]!; p++) {
let q = preds[p]!;
if (idom[q] === -1) continue;
if (ni === -1) {
ni = q;
continue;
}
let a = ni;
while (a !== q) {
while (a < q) a = idom[a]!;
while (q < a) q = idom[q]!;
}
ni = a;
}
if (ni !== -1 && idom[b] !== ni) {
idom[b] = ni;
changed = true;
}
}
}
const retained = new Float64Array(R);
for (let b = 0; b < R; b++) retained[b] += self(post[b]!);
for (let b = 0; b < R; b++) {
if (b !== rootPo && idom[b]! >= 0) retained[idom[b]!] += retained[b]!;
}
// A retaining path: the first strong, named predecessor edge, walked up.
const retainerEdge = new Map<number, [number, string]>();
const wanted = new Set<number>();
const candidates: number[] = [];
for (let b = 0; b < R; b++) {
const n = post[b]!;
const t = typeOf(n);
if (b === rootPo || t === "synthetic" || t === "hidden") continue;
// Skip nodes whose immediate dominator is itself a big application
// object: report the outermost holders only.
if (retained[b]! < 2_000_000) continue;
candidates.push(b);
}
candidates.sort((a, b) => retained[b]! - retained[a]!);
const picked: number[] = [];
for (const b of candidates) {
// keep the outermost: skip if an ancestor in the dominator tree is
// already picked and within 1.5× (it would be a duplicate line)
let a = idom[b]!;
let dup = false;
while (a !== rootPo && a >= 0) {
if (picked.includes(a) && retained[a]! < retained[b]! * 1.5) {
dup = true;
break;
}
a = idom[a]!;
}
if (!dup) picked.push(b);
if (picked.length >= 40) break;
}
for (const b of picked) wanted.add(post[b]!);
// Build one retainer per node in a BFS from the root (shortest path).
const dist = new Int32Array(N).fill(-1);
const queue = new Int32Array(N);
let qh = 0;
let qt = 0;
queue[qt++] = 0;
dist[0] = 0;
while (qh < qt) {
const n = queue[qh++]!;
for (let e = firstEdge[n]!; e < firstEdge[n + 1]!; e += EF) {
const et = edges[e + eType]!;
if (!isStrong(et)) continue;
const to = edges[e + eTo]! / NF;
if (dist[to] !== -1) continue;
dist[to] = dist[n]! + 1;
const label =
et === elementT || et === hiddenT
? `[${edges[e + eName]}]`
: String(strings[edges[e + eName]!] ?? "");
retainerEdge.set(to, [n, label]);
queue[qt++] = to;
}
}
const pathOf = (n: number) => {
const parts: string[] = [];
let cur = n;
for (let depth = 0; depth < 12 && cur !== 0; depth++) {
const r = retainerEdge.get(cur);
if (!r) break;
parts.unshift(`${r[1]}`);
cur = r[0];
const t = typeOf(cur);
if (t !== "hidden" && t !== "synthetic" && depth > 0) {
parts.unshift(`${nameOf(cur).slice(0, 40)}`);
if (depth > 4) break;
}
}
return parts.join(" > ");
};
const mb = (n: number) => Math.round((n / 1048576) * 10) / 10;
const probeResults: HeapSummary["probes"] = {};
const propertyT = edgeTypes.indexOf("property");
for (const [label, props] of Object.entries(probes)) {
const hits = new Set<number>();
for (let n = 0; n < N; n++) {
if (typeOf(n) !== "object" || order[n]! < 0) continue;
const seen = new Set<string>();
for (let e = firstEdge[n]!; e < firstEdge[n + 1]!; e += EF) {
if (edges[e + eType] !== propertyT) continue;
seen.add(strings[edges[e + eName]!]!);
}
if (props.every((p) => seen.has(p))) hits.add(order[n]!);
}
let retainedSum = 0;
for (const b of hits) {
// Count a hit only when no dominator above it is a hit too.
let a = idom[b]!;
let nested = false;
while (a !== rootPo && a >= 0) {
if (hits.has(a)) {
nested = true;
break;
}
a = idom[a]!;
}
if (!nested) retainedSum += retained[b]!;
}
probeResults[label] = { count: hits.size, retainedMb: mb(retainedSum) };
}
return {
probes: probeResults,
totalMb: mb(total),
nodeCount: N,
byType: [...byType.entries()]
.map(([k, v]) => [k, mb(v)] as [string, number])
.sort((a, b) => b[1] - a[1]),
byConstructor: [...byCtor.entries()]
.map(([k, [s, c]]) => [k, mb(s), c] as [string, number, number])
.sort((a, b) => b[1] - a[1])
.slice(0, 30),
largestStrings: big
.sort((a, b) => b[0] - a[0])
.slice(0, 25)
.map(([s, t]) => [mb(s), t]),
topRetainers: picked.map((b) => ({
mb: mb(retained[b]!),
name: `${typeOf(post[b]!)}:${nameOf(post[b]!).slice(0, 60).replace(/\s+/g, " ")}`,
path: pathOf(post[b]!),
})),
sourceStringsMb: mb(sourceStrings),
};
}
+254
View File
@@ -0,0 +1,254 @@
{
"meta": {
"label": "v1.44.0-baseline",
"commit": "d640eea32",
"date": "2026-10-22T03:54:05.746Z",
"node": "v22.23.2",
"postgres": "16.15",
"cpus": 14,
"loadAverage": [
9.4,
20.1,
20
],
"measurements": 1266549,
"iterations": 20,
"warmup": 3,
"heapMb": 792,
"idleStatementsPerSecond": 0,
"batch5k": {
"totalMs": 1722,
"totalStatements": 424
}
},
"results": [
{
"name": "/api/auth/me",
"mode": "cold",
"request": "GET /api/auth/me",
"n": 20,
"p50Ms": 12.1,
"p95Ms": 17.4,
"maxMs": 18.4,
"statementsMedian": 19,
"statementsMax": 19,
"failures": 0
},
{
"name": "/api/auth/me",
"mode": "warm",
"request": "GET /api/auth/me",
"n": 20,
"p50Ms": 16.6,
"p95Ms": 22.2,
"maxMs": 24.4,
"statementsMedian": 19,
"statementsMax": 19,
"failures": 0
},
{
"name": "dashboard summary",
"mode": "cold",
"request": "GET /api/dashboard/summary",
"n": 20,
"p50Ms": 205.6,
"p95Ms": 233.2,
"maxMs": 275.3,
"statementsMedian": 31,
"statementsMax": 32,
"failures": 0
},
{
"name": "dashboard summary",
"mode": "warm",
"request": "GET /api/dashboard/summary",
"n": 20,
"p50Ms": 12.8,
"p95Ms": 14.3,
"maxMs": 27.5,
"statementsMedian": 7,
"statementsMax": 7,
"failures": 0
},
{
"name": "dashboard snapshot",
"mode": "cold",
"request": "GET /api/dashboard/snapshot",
"n": 20,
"p50Ms": 2157.8,
"p95Ms": 2252.7,
"maxMs": 2292.9,
"statementsMedian": 158,
"statementsMax": 161,
"failures": 0
},
{
"name": "dashboard snapshot",
"mode": "warm",
"request": "GET /api/dashboard/snapshot",
"n": 20,
"p50Ms": 15.6,
"p95Ms": 23.5,
"maxMs": 25.6,
"statementsMedian": 13,
"statementsMax": 16,
"failures": 0
},
{
"name": "series pulse 30 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=30",
"n": 20,
"p50Ms": 50.8,
"p95Ms": 58.7,
"maxMs": 58.9,
"statementsMedian": 8,
"statementsMax": 9,
"failures": 0
},
{
"name": "series pulse 30 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=30",
"n": 20,
"p50Ms": 52.3,
"p95Ms": 59.5,
"maxMs": 59.9,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "series pulse 365 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=365",
"n": 20,
"p50Ms": 289,
"p95Ms": 311.3,
"maxMs": 363.7,
"statementsMedian": 8,
"statementsMax": 9,
"failures": 0
},
{
"name": "series pulse 365 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=365",
"n": 20,
"p50Ms": 274.3,
"p95Ms": 355.8,
"maxMs": 366.8,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "series pulse 3650 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=3650",
"n": 20,
"p50Ms": 1502.6,
"p95Ms": 2117.6,
"maxMs": 2122.9,
"statementsMedian": 8,
"statementsMax": 9,
"failures": 0
},
{
"name": "series pulse 3650 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=3650",
"n": 20,
"p50Ms": 1490.3,
"p95Ms": 1505.5,
"maxMs": 1555.2,
"statementsMedian": 8,
"statementsMax": 11,
"failures": 0
},
{
"name": "day view",
"mode": "cold",
"request": "GET /api/day/2026-10-21",
"n": 20,
"p50Ms": 61.7,
"p95Ms": 77.5,
"maxMs": 86.1,
"statementsMedian": 49,
"statementsMax": 50,
"failures": 0
},
{
"name": "day view",
"mode": "warm",
"request": "GET /api/day/2026-10-21",
"n": 20,
"p50Ms": 49,
"p95Ms": 55.6,
"maxMs": 62.5,
"statementsMedian": 48,
"statementsMax": 48,
"failures": 0
},
{
"name": "insights health status",
"mode": "cold",
"request": "GET /api/insights/health-status",
"n": 20,
"p50Ms": 124.1,
"p95Ms": 139.5,
"maxMs": 141.5,
"statementsMedian": 68,
"statementsMax": 68,
"failures": 0
},
{
"name": "insights health status",
"mode": "warm",
"request": "GET /api/insights/health-status",
"n": 20,
"p50Ms": 124.7,
"p95Ms": 157.8,
"maxMs": 165.3,
"statementsMedian": 68,
"statementsMax": 68,
"failures": 0
},
{
"name": "insights correlations",
"mode": "cold",
"request": "GET /api/insights/correlations",
"n": 20,
"p50Ms": 95.4,
"p95Ms": 105,
"maxMs": 108.8,
"statementsMedian": 45,
"statementsMax": 45,
"failures": 0
},
{
"name": "insights correlations",
"mode": "warm",
"request": "GET /api/insights/correlations",
"n": 20,
"p50Ms": 13,
"p95Ms": 15.4,
"maxMs": 15.9,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "batch ingest 5k (per 500)",
"mode": "write",
"request": "POST /api/measurements/batch ×10",
"n": 10,
"p50Ms": 163,
"p95Ms": 220.7,
"maxMs": 220.7,
"statementsMedian": 42,
"statementsMax": 45,
"failures": 0
}
]
}
+25
View File
@@ -0,0 +1,25 @@
# Benchmark v1.44.0-baseline
Commit d640eea32, 2026-10-22T03:54:05.746Z. 1266549 measurement rows on the bench account, 20 measured requests per row after 3 warm-up requests, one at a time. Server: production build, web process, heap ceiling 792 MB. Postgres 16.15, 14 cores, load average 9.4,20.1,20 at the end. Idle background: 0 statements/s.
| Request | Mode | n | p50 ms | p95 ms | max ms | statements (median) | statements (max) |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
| /api/auth/me | cold | 20 | 12.1 | 17.4 | 18.4 | 19 | 19 |
| /api/auth/me | warm | 20 | 16.6 | 22.2 | 24.4 | 19 | 19 |
| dashboard summary | cold | 20 | 205.6 | 233.2 | 275.3 | 31 | 32 |
| dashboard summary | warm | 20 | 12.8 | 14.3 | 27.5 | 7 | 7 |
| dashboard snapshot | cold | 20 | 2157.8 | 2252.7 | 2292.9 | 158 | 161 |
| dashboard snapshot | warm | 20 | 15.6 | 23.5 | 25.6 | 13 | 16 |
| series pulse 30 d | cold | 20 | 50.8 | 58.7 | 58.9 | 8 | 9 |
| series pulse 30 d | warm | 20 | 52.3 | 59.5 | 59.9 | 8 | 8 |
| series pulse 365 d | cold | 20 | 289 | 311.3 | 363.7 | 8 | 9 |
| series pulse 365 d | warm | 20 | 274.3 | 355.8 | 366.8 | 8 | 8 |
| series pulse 3650 d | cold | 20 | 1502.6 | 2117.6 | 2122.9 | 8 | 9 |
| series pulse 3650 d | warm | 20 | 1490.3 | 1505.5 | 1555.2 | 8 | 11 |
| day view | cold | 20 | 61.7 | 77.5 | 86.1 | 49 | 50 |
| day view | warm | 20 | 49 | 55.6 | 62.5 | 48 | 48 |
| insights health status | cold | 20 | 124.1 | 139.5 | 141.5 | 68 | 68 |
| insights health status | warm | 20 | 124.7 | 157.8 | 165.3 | 68 | 68 |
| insights correlations | cold | 20 | 95.4 | 105 | 108.8 | 45 | 45 |
| insights correlations | warm | 20 | 13 | 15.4 | 15.9 | 8 | 8 |
| batch ingest 5k (per 500) | write | 10 | 163 | 220.7 | 220.7 | 42 | 45 |
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,221 @@
# Boot profile v1.45.0-before-boot
Commit 0f6d668df, 2026-10-22T04:36:55.826Z. Production build, every role, heap ceiling 792 MB, 300 s idle. 14 cores, load average 9.3,9.4,11.2 at the end.
- first answer from /api/health: 1590 ms; background role ready: 5432 ms
- event-loop stalls logged: 0, max 0 ms
## Stalls
## Heap samples
| at s | used MB | old | large obj | code | external | array buffers | rss |
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| 6 | 184 | 105 | 68 | 4 | 11 | 0 | 267 |
| 308 | 164 | 99 | 58 | 5 | 11 | 0 | 623 |
## Full collections (mark-compact)
47 mark-compacts during the run. Used heap before the collection, and what survived it (the live heap):
| at s | before MB | after MB |
| ---: | ---: | ---: |
| 0 | 21.4 | 17.4 |
| 9 | 189.6 | 172.9 |
| 154 | 193.8 | 171.6 |
| 170 | 187.3 | 161.7 |
| 188 | 181.6 | 163.6 |
| 205 | 182.3 | 163.6 |
| 222 | 190.6 | 165 |
| 239 | 182.9 | 163.4 |
| 258 | 190.5 | 163.7 |
| 274 | 187.7 | 164.2 |
| 290 | 193.9 | 164.2 |
| 306 | 175.3 | 163.9 |
| 309 | 174 | 163.7 |
## Main-thread busy stretches over 50 ms without an idle sample, first two minutes
### at 5 ms, 279 ms
| self time by module | ms |
| --- | ---: |
| (native) | 122.3 |
| npm:next | 57.1 |
| node:internal/modules/cjs/loader | 41.7 |
| node:internal/bootstrap/realm | 24.7 |
| node:fs | 5.3 |
| node:internal/deps/undici/undici | 5.3 |
| node:internal/modules/package_json_reader | 4.5 |
| node:path | 4 |
| node:internal/streams/destroy | 1.5 |
| node:internal/deps/cjs-module-lexer/lexer | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
### at 339 ms, 324 ms
| self time by module | ms |
| --- | ---: |
| (native) | 87.6 |
| node:internal/modules/cjs/loader | 54.2 |
| npm:@prisma/client | 38 |
| wasm://wasm/00d1cb12 | 36 |
| node:internal/modules/esm/utils | 26.7 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 24.9 |
| npm:next | 22.1 |
| npm:zod | 6 |
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 3.1 |
| npm:@prisma/client-runtime-utils | 3 |
| inclusive, application modules | ms |
| --- | ---: |
| src/instrumentation.ts | 80.6 |
| src/lib/db-session.ts | 60.7 |
| src/lib/notifications/types.ts | 13.2 |
| src/lib/boot/readiness-summary.ts | 13.2 |
| src/lib/ai/local-host-allowlist.ts | 13.2 |
| src/generated/prisma/internal/class.ts | 7.3 |
| src/lib/logging/context.ts | 6 |
| src/lib/crypto.ts | 6 |
| src/lib/boot/key-check.ts | 6 |
| src/lib/validations/measurement.ts | 3 |
### at 698 ms, 753 ms
| self time by module | ms |
| --- | ---: |
| node:internal/modules/cjs/loader | 178.2 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 177.4 |
| (native) | 83.8 |
| npm:zod | 39.5 |
| npm:next | 34.6 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 33.9 |
| npm:mmdb-lib | 26.1 |
| node:fs | 23.5 |
| npm:yaml | 22.4 |
| src/lib/activity/goal-bounds.ts | 18.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/instrumentation.ts | 410 |
| src/lib/jobs/reminder/register-maintenance.ts | 279.5 |
| src/lib/sharing/acting-session.ts | 138.5 |
| src/lib/jobs/offhost-backup.ts | 137.1 |
| src/lib/integrations/oauth-refresh.ts | 136.3 |
| src/lib/jobs/morning-digest-refresh-shared.ts | 136.3 |
| src/lib/export/environment-backup.ts | 134.1 |
| src/lib/export/full-backup-payload.ts | 134.1 |
| src/app/api/health/route.ts | 127.5 |
| src/lib/insights/snapshot-hash.ts | 124.9 |
## Self time by module until the worker is ready
| module | ms |
| --- | ---: |
| (native) | 524.6 |
| node:internal/modules/cjs/loader | 274 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 202.3 |
| npm:next | 136.8 |
| wasm://wasm/00d1cb12 | 108.1 |
| npm:@prisma/client | 76.1 |
| npm:pg | 56.5 |
| npm:zod | 45.5 |
| node:internal/modules/esm/utils | 41.4 |
| npm:luxon | 38.5 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 35.3 |
| node:fs | 31.7 |
| node:internal/bootstrap/realm | 28.9 |
| npm:pg-protocol | 27.7 |
| npm:mmdb-lib | 26.1 |
| npm:yaml | 22.4 |
| npm:pg-boss | 19.3 |
| src/lib/activity/goal-bounds.ts | 18.5 |
| node:internal/process/task_queues | 17.7 |
| node:internal/streams/readable | 15.5 |
| node:internal/async_hooks | 15.1 |
| npm:cron-parser | 14.6 |
| node:internal/timers | 12.8 |
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 12.4 |
| node:buffer | 11.6 |
## Heap snapshot after 300 s idle (live heap after a full GC)
172.3 MB in 1412114 nodes; module source strings over 50 KB: 44.2 MB.
- parsed locale catalogs: 32 objects, 14.1 MB retained
| node type | MB |
| --- | ---: |
| string | 79.1 |
| array | 27.6 |
| code | 18.6 |
| object | 16.7 |
| hidden | 9.3 |
| native | 8 |
| object shape | 5.8 |
| closure | 5 |
| concatenated string | 1.3 |
| number | 0.4 |
| regexp | 0.2 |
| sliced string | 0.2 |
| synthetic | 0 |
| symbol | 0 |
| bigint | 0 |
| constructor | MB | count |
| --- | ---: | ---: |
| array:(object elements) | 20.6 | 24726 |
| object:Object | 11.9 | 300844 |
| native:system / JSArrayBufferData | 7.5 | 169 |
| array:(object properties) | 4.4 | 6540 |
| array: | 2.7 | 9578 |
| object:system / Context | 1.6 | 28887 |
| closure: | 1.5 | 27081 |
| object:Date1 | 1 | 10632 |
| closure:native_bind | 0.9 | 20134 |
| object:Array | 0.6 | 19550 |
| object:a | 0.4 | 3880 |
| native:Node / histogram | 0.4 | 1 |
| closure:get | 0.3 | 4717 |
| object:Set | 0.2 | 5102 |
| closure:set | 0.2 | 3414 |
| object:d | 0.2 | 3874 |
| object:Module | 0.1 | 1907 |
| object:Promise | 0.1 | 1590 |
| closure:n | 0.1 | 1400 |
| closure:i | 0.1 | 1390 |
| largest retained subtrees | MB | retaining path |
| --- | ---: | --- |
| object:yr | 33.9 | [1] > [10] > 15 > extension > global > prisma > [1] > _engine |
| object:Object | 10 | tX > onClose > onClose > context > system / Context > previous > system / Context > e > Context > __proto__ > c |
| code:.next/server/chu | 9.8 | system / Context > timerListMap > Object > [120000] > TimersList > _idleNext > Timeout > _onTimeout > > shared > script |
| code:.next/server/chu | 9.7 | Module > _cache > Object > .next/server/chunks/_09yxiz_._.js > Module > exports > Array > [1] > > shared > script |
| object:system / Context | 7.4 | Node / PrincipalRealm > base_object_list > Node / BaseObjectList > [25] > Node / ModuleWrap > native_to_javascript > ModuleWrap > 2 > [4] > context |
| code:.next/server/chu | 5.6 | global > prisma > [1] > t > _engineConfig > Object > compilerWasm > Object > getRuntime > getRuntime > shared > script |
| code:.next/server/chu | 5.6 | Module > _cache > Object > .next/server/chunks/_0hfea7u._.js > Module > exports > Array > [1] > > shared > script |
| object:Generator | 4.4 | [6] > principal_realm > Node / PrincipalRealm > base_object_list > Node / BaseObjectList > [26] > Node / ModuleWrap > native_to_javascript > ModuleWrap > 2 > [4 |
| string:AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/ | 4.4 | [1] > [21] > 59867 |
| code:.next/server/chu | 3.7 | [1] > [10] > 15 > extension > global > __healthlog_pgboss__ > PgBoss > _events > Object > error > > shared > script |
| object:Client | 3.6 | Object > close > > context > system / Context > self > Connection > _events > Object > parseComplete > bound_this |
| largest strings | MB |
| --- | ---: |
| module.exports=[425781,(e,a,i)=>{a.exports=JSON.parse('{"common":{"listCapped":"Hier stehe | 9.7 |
| module.exports=[157893,(e,a,i)=>{a.exports=JSON.parse('{"common":{"listCapped":"Hier stehe | 9.7 |
| module.exports=[17856,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=>i | 5.6 |
| module.exports=[234494,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=> | 5.6 |
| const wasm = "AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/fwBgBX9/f39/AGAB | 4.4 |
| AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/fwBgBX9/f39/AGABfwF/YAZ/f39/f3 | 4.4 |
| module.exports=[799092,(e,t,n)=>{"use strict";Object.defineProperty(n,"__esModule",{value: | 3.7 |
| lliADOAUngIEAACgKwAgBgAAoSsAIAcAAKErACAIAACiKwAgCQAAoysAIAoAAKQrACALAAClKwAgDAAApisAIA0AAK | 1.9 |
| (()=>{var e,t,r={"./dist/build/webpack/alias/react-dom-server.js"(e,t,r){"use strict";var | 1.3 |
| module.exports=[842865,(e,t,r)=>{let i=e.r(522734);t.exports=function(e){return e?/-----BE | 1.2 |
| module.exports=[834886,(e,t,n)=>{"use strict";Object.defineProperty(n,"__esModule",{value: | 1.1 |
| // v1.4.34 web-freeze. Schema is locked additive-only until iOS launch // + v1.5.0 marker. | 1.1 |
| module.exports=[971743,(A,e,t)=>{"use strict";let r=A.r(57328),{addAbortListener:s}=A.r(68 | 1.1 |
| module.exports=[835717,(A,e,t)=>{"use strict";let r=A.r(57328),{addAbortListener:s}=A.r(68 | 1 |
| module.exports=[594108,806176,674750,98746,t=>{"use strict";var i;let r;function n(t){retu | 0.7 |
+254
View File
@@ -0,0 +1,254 @@
{
"meta": {
"label": "v1.45.0-before",
"commit": "1915f9152",
"date": "2026-10-22T05:51:47.626Z",
"node": "v22.23.2",
"postgres": "16.15",
"cpus": 14,
"loadAverage": [
29.9,
21.9,
18.9
],
"measurements": 1265561,
"iterations": 20,
"warmup": 3,
"heapMb": 792,
"idleStatementsPerSecond": 0,
"batch5k": {
"totalMs": 1730,
"totalStatements": 534
}
},
"results": [
{
"name": "/api/auth/me",
"mode": "cold",
"request": "GET /api/auth/me",
"n": 20,
"p50Ms": 9.2,
"p95Ms": 11.6,
"maxMs": 13.3,
"statementsMedian": 19,
"statementsMax": 19,
"failures": 0
},
{
"name": "/api/auth/me",
"mode": "warm",
"request": "GET /api/auth/me",
"n": 20,
"p50Ms": 8.6,
"p95Ms": 10.5,
"maxMs": 13,
"statementsMedian": 19,
"statementsMax": 19,
"failures": 0
},
{
"name": "dashboard summary",
"mode": "cold",
"request": "GET /api/dashboard/summary",
"n": 20,
"p50Ms": 170.2,
"p95Ms": 220.5,
"maxMs": 228.3,
"statementsMedian": 31,
"statementsMax": 34,
"failures": 0
},
{
"name": "dashboard summary",
"mode": "warm",
"request": "GET /api/dashboard/summary",
"n": 20,
"p50Ms": 6,
"p95Ms": 6.7,
"maxMs": 6.8,
"statementsMedian": 7,
"statementsMax": 7,
"failures": 0
},
{
"name": "dashboard snapshot",
"mode": "cold",
"request": "GET /api/dashboard/snapshot",
"n": 20,
"p50Ms": 1481.8,
"p95Ms": 1526.2,
"maxMs": 1530,
"statementsMedian": 158,
"statementsMax": 161,
"failures": 0
},
{
"name": "dashboard snapshot",
"mode": "warm",
"request": "GET /api/dashboard/snapshot",
"n": 20,
"p50Ms": 7,
"p95Ms": 7.9,
"maxMs": 10.2,
"statementsMedian": 13,
"statementsMax": 13,
"failures": 0
},
{
"name": "series pulse 30 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=30",
"n": 20,
"p50Ms": 24.4,
"p95Ms": 30.8,
"maxMs": 31.1,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "series pulse 30 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=30",
"n": 20,
"p50Ms": 20.5,
"p95Ms": 29.4,
"maxMs": 30.2,
"statementsMedian": 8,
"statementsMax": 9,
"failures": 0
},
{
"name": "series pulse 365 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=365",
"n": 20,
"p50Ms": 184.1,
"p95Ms": 192.6,
"maxMs": 201.1,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "series pulse 365 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=365",
"n": 20,
"p50Ms": 178.1,
"p95Ms": 186.5,
"maxMs": 187.6,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "series pulse 3650 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=3650",
"n": 20,
"p50Ms": 1627,
"p95Ms": 1664.5,
"maxMs": 1675.7,
"statementsMedian": 8,
"statementsMax": 9,
"failures": 0
},
{
"name": "series pulse 3650 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=3650",
"n": 20,
"p50Ms": 2090.9,
"p95Ms": 2435.4,
"maxMs": 2529.7,
"statementsMedian": 8,
"statementsMax": 9,
"failures": 0
},
{
"name": "day view",
"mode": "cold",
"request": "GET /api/day/2026-10-21",
"n": 20,
"p50Ms": 76.7,
"p95Ms": 93.6,
"maxMs": 116.2,
"statementsMedian": 49,
"statementsMax": 49,
"failures": 0
},
{
"name": "day view",
"mode": "warm",
"request": "GET /api/day/2026-10-21",
"n": 20,
"p50Ms": 62.8,
"p95Ms": 72,
"maxMs": 73.5,
"statementsMedian": 48,
"statementsMax": 48,
"failures": 0
},
{
"name": "insights health status",
"mode": "cold",
"request": "GET /api/insights/health-status",
"n": 20,
"p50Ms": 244.7,
"p95Ms": 401.2,
"maxMs": 404.1,
"statementsMedian": 68,
"statementsMax": 69,
"failures": 0
},
{
"name": "insights health status",
"mode": "warm",
"request": "GET /api/insights/health-status",
"n": 20,
"p50Ms": 238.9,
"p95Ms": 399.1,
"maxMs": 476.6,
"statementsMedian": 68,
"statementsMax": 68,
"failures": 0
},
{
"name": "insights correlations",
"mode": "cold",
"request": "GET /api/insights/correlations",
"n": 20,
"p50Ms": 105.6,
"p95Ms": 326.9,
"maxMs": 349.7,
"statementsMedian": 49,
"statementsMax": 52,
"failures": 0
},
{
"name": "insights correlations",
"mode": "warm",
"request": "GET /api/insights/correlations",
"n": 20,
"p50Ms": 11.3,
"p95Ms": 16.6,
"maxMs": 20.3,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "batch ingest 5k (per 500)",
"mode": "write",
"request": "POST /api/measurements/batch ×10",
"n": 10,
"p50Ms": 166.1,
"p95Ms": 220.3,
"maxMs": 220.3,
"statementsMedian": 53,
"statementsMax": 56,
"failures": 0
}
]
}
+25
View File
@@ -0,0 +1,25 @@
# Benchmark v1.45.0-before
Commit 1915f9152 (the v1.44 runtime this branch starts from), 2026-10-22T05:51:47.626Z. 1265561 measurement rows on the bench account, 20 measured requests per row after 3 warm-up requests, one at a time. Server: production build, web process, heap ceiling 792 MB. Postgres 16.15, 14 cores, load average 29.9,21.9,18.9 at the end. Idle background: 0 statements/s.
| Request | Mode | n | p50 ms | p95 ms | max ms | statements (median) | statements (max) |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
| /api/auth/me | cold | 20 | 9.2 | 11.6 | 13.3 | 19 | 19 |
| /api/auth/me | warm | 20 | 8.6 | 10.5 | 13 | 19 | 19 |
| dashboard summary | cold | 20 | 170.2 | 220.5 | 228.3 | 31 | 34 |
| dashboard summary | warm | 20 | 6 | 6.7 | 6.8 | 7 | 7 |
| dashboard snapshot | cold | 20 | 1481.8 | 1526.2 | 1530 | 158 | 161 |
| dashboard snapshot | warm | 20 | 7 | 7.9 | 10.2 | 13 | 13 |
| series pulse 30 d | cold | 20 | 24.4 | 30.8 | 31.1 | 8 | 8 |
| series pulse 30 d | warm | 20 | 20.5 | 29.4 | 30.2 | 8 | 9 |
| series pulse 365 d | cold | 20 | 184.1 | 192.6 | 201.1 | 8 | 8 |
| series pulse 365 d | warm | 20 | 178.1 | 186.5 | 187.6 | 8 | 8 |
| series pulse 3650 d | cold | 20 | 1627 | 1664.5 | 1675.7 | 8 | 9 |
| series pulse 3650 d | warm | 20 | 2090.9 | 2435.4 | 2529.7 | 8 | 9 |
| day view | cold | 20 | 76.7 | 93.6 | 116.2 | 49 | 49 |
| day view | warm | 20 | 62.8 | 72 | 73.5 | 48 | 48 |
| insights health status | cold | 20 | 244.7 | 401.2 | 404.1 | 68 | 69 |
| insights health status | warm | 20 | 238.9 | 399.1 | 476.6 | 68 | 68 |
| insights correlations | cold | 20 | 105.6 | 326.9 | 349.7 | 49 | 52 |
| insights correlations | warm | 20 | 11.3 | 16.6 | 20.3 | 8 | 8 |
| batch ingest 5k (per 500) | write | 10 | 166.1 | 220.3 | 220.3 | 53 | 56 |
File diff suppressed because it is too large. Load diff
+298
View File
@@ -0,0 +1,298 @@
# Boot profile v1.45.0-boot-after
Commit a859adb59, 2026-10-22T04:49:50.419Z. Production build, every role, heap ceiling 792 MB, 300 s idle. 14 cores, load average 8.6,9.4,10.7 at the end.
- first answer from /api/health: 850 ms; background role ready: 3038 ms
- event-loop stalls logged: 0, max 0 ms
## Stalls
## Heap samples
| at s | used MB | old | large obj | code | external | array buffers | rss |
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| 6 | 166 | 91 | 53 | 3 | 12 | 1 | 430 |
| 306 | 153 | 90 | 53 | 4 | 11 | 0 | 599 |
## Full collections (mark-compact)
10 mark-compacts during the run. Used heap before the collection, and what survived it (the live heap):
| at s | before MB | after MB |
| ---: | ---: | ---: |
| 0 | 20.2 | 17.1 |
| 0 | 55.3 | 52.6 |
| 0 | 83.2 | 81.1 |
| 1 | 148.4 | 132.7 |
| 16 | 159.6 | 153.2 |
| 17 | 153.4 | 151 |
| 62 | 167.5 | 157.5 |
| 246 | 183.4 | 152.6 |
| 303 | 165.6 | 151.8 |
| 303 | 151.8 | 151.8 |
## Main-thread busy stretches over 50 ms without an idle sample, first two minutes
### at 6 ms, 175 ms
| self time by module | ms |
| --- | ---: |
| npm:next | 47.2 |
| node:internal/modules/cjs/loader | 34.8 |
| (native) | 34.5 |
| node:internal/bootstrap/realm | 25.7 |
| node:internal/deps/undici/undici | 8.8 |
| node:path | 6.3 |
| node:internal/modules/package_json_reader | 3 |
| node:internal/net | 3 |
| node:internal/modules/esm/resolve | 2.5 |
| node:fs | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
### at 203 ms, 241 ms
| self time by module | ms |
| --- | ---: |
| (native) | 52.7 |
| node:internal/modules/cjs/loader | 40.1 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 31.7 |
| wasm://wasm/00d1cb12 | 28.7 |
| npm:@prisma/client | 28 |
| npm:next | 16.3 |
| node:internal/modules/esm/utils | 12 |
| node:fs | 4.5 |
| npm:zod | 3 |
| node:internal/modules/helpers | 3 |
| inclusive, application modules | ms |
| --- | ---: |
| src/instrumentation.ts | 80.8 |
| src/lib/db-session.ts | 49.5 |
| src/lib/boot/startup-state.ts | 25.6 |
| src/lib/boot/boot-steps.ts | 25.6 |
| src/lib/logging/background.ts | 25.6 |
| src/lib/process-type.ts | 25.5 |
| src/lib/logging/context.ts | 13.6 |
| src/lib/validations/measurement.ts | 12.1 |
| src/lib/logging/redact.ts | 10.5 |
| src/lib/validations/entry-instant.ts | 4.6 |
### at 501 ms, 178 ms
| self time by module | ms |
| --- | ---: |
| node:internal/modules/cjs/loader | 53.9 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 37.5 |
| npm:next | 18 |
| npm:zod | 12 |
| node:internal/modules/esm/utils | 10.5 |
| (native) | 8.7 |
| node:fs | 6 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 4.5 |
| npm:undici | 4.5 |
| node:internal/url | 1.8 |
| inclusive, application modules | ms |
| --- | ---: |
| src/app/api/health/route.ts | 61.4 |
| src/lib/api-errors.ts | 27 |
| src/lib/jobs/job-failures.ts | 14.9 |
| src/lib/jobs/offhost-backup.ts | 14.9 |
| src/lib/managed-profiles/handover-access.ts | 14.9 |
| src/lib/sharing/acting-session.ts | 14.9 |
| src/lib/db-session.ts | 13.4 |
| src/lib/export/environment-backup.ts | 13.4 |
| src/lib/export/full-backup-payload.ts | 13.4 |
| src/lib/logging/background.ts | 10.5 |
### at 833 ms, 184 ms
| self time by module | ms |
| --- | ---: |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 38.9 |
| node:internal/modules/cjs/loader | 28.6 |
| (native) | 22.8 |
| src/lib/i18n/server-catalogs.ts | 15.1 |
| npm:yaml | 15 |
| node:fs | 12.1 |
| npm:zod | 9 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 5.9 |
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 4.5 |
| npm:semver | 3 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/jobs/reminder-worker.ts | 129.1 |
| src/lib/jobs/reminder/google-health-sync.ts | 88.5 |
| src/lib/whoop/sync.ts | 79.5 |
| src/lib/whoop/sync-body.ts | 63 |
| src/lib/insights/bucket-series.ts | 60 |
| src/lib/whoop/oauth-descriptor.ts | 60 |
| src/lib/whoop/sync-core.ts | 60 |
| src/instrumentation.ts | 43.4 |
| src/lib/import/unzip-export-xml.ts | 31.4 |
| src/lib/jobs/integration-status-null-probe.ts | 31.4 |
### at 1099 ms, 54 ms
| self time by module | ms |
| --- | ---: |
| node:internal/modules/cjs/loader | 23.9 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 10.6 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 5.8 |
| src/lib/daily/today-overview.ts | 4.5 |
| src/lib/jobs/reminder-worker.ts | 3 |
| node:fs | 3 |
| npm:pg-boss | 1.5 |
| npm:zod | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/jobs/reminder-worker.ts | 52.3 |
| src/lib/jobs/reminder/insights-handlers.ts | 22.3 |
| src/lib/ai/prompts/arrival-reaction.ts | 12 |
| src/lib/daily/score-steady.ts | 10.5 |
| src/lib/dashboard/bands.ts | 6 |
| src/lib/jobs/data-arrival.ts | 4.5 |
| src/lib/dashboard/snapshot-read.ts | 4.5 |
| src/lib/daily/today-overview.ts | 4.5 |
| src/lib/daily/priority-item-key.ts | 4.5 |
| src/lib/ai/prompts/status-batch.ts | 3 |
### at 1309 ms, 51 ms
| self time by module | ms |
| --- | ---: |
| node:internal/modules/cjs/loader | 28 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 6 |
| node:fs | 4.5 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 3 |
| turbopack:///[turbopack]/nodejs/runtime/runtime-base.ts | 1.5 |
| src/lib/jobs/failure-condition.ts | 1.5 |
| src/lib/labs/parse-reference-range.ts | 1.5 |
| npm:zod | 1.5 |
| node:internal/linkedlist | 1.5 |
| (native) | 0.9 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/jobs/reminder-worker.ts | 48.2 |
| src/lib/jobs/reminder/ops-handlers.ts | 14.1 |
| src/lib/medications/ingredients/label-extract.ts | 3 |
| src/lib/export/restore-backup.ts | 2.1 |
| src/lib/jobs/failure-condition.ts | 1.5 |
| src/lib/documents/ai-runs/summary-run.ts | 1.5 |
| src/lib/documents/auto-stage-labs.ts | 1.5 |
| src/lib/documents/ai-runs/store.ts | 1.5 |
| src/lib/labs/parse-reference-range.ts | 1.5 |
| src/lib/labs/ocr-upload.ts | 1.5 |
## Self time by module until the worker is ready
| module | ms |
| --- | ---: |
| (native) | 230.8 |
| node:internal/modules/cjs/loader | 223.6 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 124.7 |
| npm:next | 98.8 |
| wasm://wasm/00d1cb12 | 62.6 |
| npm:@prisma/client | 56.2 |
| node:fs | 34.6 |
| node:internal/bootstrap/realm | 28.7 |
| npm:pg | 28.3 |
| npm:zod | 27 |
| npm:pg-boss | 24.6 |
| node:internal/modules/esm/utils | 22.5 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 20.6 |
| npm:luxon | 17 |
| src/lib/i18n/server-catalogs.ts | 15.1 |
| npm:yaml | 15 |
| npm:pg-pool | 13 |
| node:internal/deps/undici/undici | 10.4 |
| node:path | 7.8 |
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 7.5 |
| node:internal/async_hooks | 7.5 |
| node:internal/process/task_queues | 7.3 |
| node:internal/modules/package_json_reader | 6 |
| npm:undici | 6 |
| npm:pg-protocol | 6 |
## Heap snapshot after 300 s idle (live heap after a full GC)
160.1 MB in 1275329 nodes; module source strings over 50 KB: 39.8 MB.
- parsed locale catalogs: 8 objects, 7 MB retained
| node type | MB |
| --- | ---: |
| string | 68.7 |
| array | 26.4 |
| code | 21.1 |
| object | 14.1 |
| hidden | 8.9 |
| native | 8 |
| object shape | 5.9 |
| closure | 5.2 |
| concatenated string | 1.3 |
| regexp | 0.2 |
| sliced string | 0.2 |
| number | 0.1 |
| synthetic | 0 |
| symbol | 0 |
| bigint | 0 |
| constructor | MB | count |
| --- | ---: | ---: |
| array:(object elements) | 20.4 | 23797 |
| object:Object | 10.2 | 274231 |
| native:system / JSArrayBufferData | 7.6 | 171 |
| array:(object properties) | 3.6 | 6512 |
| array: | 2.4 | 10502 |
| closure: | 1.6 | 29042 |
| object:system / Context | 1.6 | 28873 |
| closure:native_bind | 1 | 20782 |
| object:Array | 0.6 | 19679 |
| object:a | 0.4 | 4438 |
| native:Node / histogram | 0.4 | 1 |
| closure:get | 0.3 | 4736 |
| object:d | 0.3 | 4425 |
| object:Set | 0.2 | 5673 |
| closure:set | 0.2 | 3432 |
| object:Module | 0.2 | 1958 |
| closure:i | 0.1 | 1581 |
| closure:n | 0.1 | 1572 |
| object:Promise | 0.1 | 1277 |
| object:n | 0.1 | 1881 |
| largest retained subtrees | MB | retaining path |
| --- | ---: | --- |
| object:t | 33.9 | [1] > [10] > 60 > extension > global > prisma > [1] |
| object:global | 7.9 | [1] > [10] > 60 > extension |
| object:system / Context | 7.4 | Node / PrincipalRealm > base_object_list > Node / BaseObjectList > [25] > Node / ModuleWrap > native_to_javascript > ModuleWrap > 2 > [4] > context |
| object:Module | 5.6 | [1] > [10] > 97 > process > nextTick > patchedNextTick > context > system / Context > require > require > cache > .n |
| code:.next/server/chu | 5.6 | require > cache > Object > .next/server/chunks/_1kwfnjg._.js > Module > exports > Array > [1] > > shared > script |
| code:.next/server/chu | 5.6 | global > prisma > [1] > t > _engineConfig > Object > compilerWasm > Object > getRuntime > getRuntime > shared > script |
| object:Generator | 4.4 | [6] > principal_realm > Node / PrincipalRealm > base_object_list > Node / BaseObjectList > [26] > Node / ModuleWrap > native_to_javascript > ModuleWrap > 2 > [4 |
| string:AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/ | 4.4 | [1] > [21] > 62203 |
| object:Object | 3.3 | system / Context > require > require > cache > Object > .next/server/chunks/[turbopack]_runtime.js > Module > export |
| largest strings | MB |
| --- | ---: |
| module.exports=[234494,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=> | 5.6 |
| module.exports=[17856,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=>i | 5.6 |
| module.exports=[17856,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=>i | 5.6 |
| const wasm = "AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/fwBgBX9/f39/AGAB | 4.4 |
| AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/fwBgBX9/f39/AGABfwF/YAZ/f39/f3 | 4.4 |
| lliADOAUngIEAACgKwAgBgAAoSsAIAcAAKErACAIAACiKwAgCQAAoysAIAoAAKQrACALAAClKwAgDAAApisAIA0AAK | 1.9 |
| (()=>{var e,t,r={"./dist/build/webpack/alias/react-dom-server.js"(e,t,r){"use strict";var | 1.3 |
| module.exports=[842865,(e,t,r)=>{let i=e.r(522734);t.exports=function(e){return e?/-----BE | 1.2 |
| module.exports=[842865,(e,t,r)=>{let i=e.r(522734);t.exports=function(e){return e?/-----BE | 1.2 |
| module.exports=[799092,(e,t,n)=>{"use strict";Object.defineProperty(n,"__esModule",{value: | 1.1 |
| // v1.4.34 web-freeze. Schema is locked additive-only until iOS launch // + v1.5.0 marker. | 1.1 |
| module.exports=[971743,(A,e,t)=>{"use strict";let r=A.r(57328),{addAbortListener:s}=A.r(68 | 1.1 |
| module.exports=[835717,(A,e,t)=>{"use strict";let r=A.r(57328),{addAbortListener:s}=A.r(68 | 1 |
| module.exports=[767189,e=>e.a(async(t,a)=>{try{var n=e.i(64105),r=e.i(563616),i=t([n]);[n] | 0.8 |
| module.exports=[594108,806176,674750,98746,t=>{"use strict";var i;let r;function n(t){retu | 0.7 |
File diff suppressed because it is too large. Load diff
+804
View File
@@ -0,0 +1,804 @@
# Boot profile v1.45.0-boot-final
Commit a57fa8335, 2026-10-22T06:58:52.591Z. Production build, every role, heap ceiling 792 MB, 300 s idle. 14 cores, load average 5.6,6.4,11.1 at the end.
- first answer from /api/health: 1325 ms; background role ready: 3217 ms
- event-loop stalls logged: 0, max 0 ms
## Stalls
## Heap samples
| at s | used MB | old | large obj | code | external | array buffers | rss |
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| 6 | 225 | 158 | 54 | 4 | 11 | 0 | 500 |
| 306 | 158 | 96 | 53 | 5 | 11 | 0 | 788 |
## Full collections (mark-compact)
98 mark-compacts during the run. Used heap before the collection, and what survived it (the live heap):
| at s | before MB | after MB |
| ---: | ---: | ---: |
| 0 | 21.5 | 17.5 |
| 8 | 203.8 | 146.5 |
| 24 | 236.5 | 142.5 |
| 41 | 223.8 | 144.9 |
| 60 | 225.8 | 147.5 |
| 85 | 224.4 | 147.7 |
| 124 | 225.4 | 151.2 |
| 167 | 176.7 | 151 |
| 209 | 230.1 | 150.4 |
| 242 | 197.9 | 151.5 |
| 273 | 224.8 | 152.2 |
| 302 | 177.9 | 152.5 |
| 312 | 170.5 | 152.9 |
| 327 | 170.6 | 152 |
| 338 | 169.7 | 151.8 |
## Main-thread busy stretches over 50 ms without an idle sample, first two minutes
### at 5 ms, 396 ms
| self time by module | ms |
| --- | ---: |
| (native) | 230.2 |
| node:internal/modules/cjs/loader | 57.8 |
| npm:next | 52.3 |
| node:internal/bootstrap/realm | 29.3 |
| node:internal/deps/undici/undici | 8.9 |
| node:internal/modules/esm/loader | 4.5 |
| node:fs | 3 |
| node:internal/net | 2.5 |
| node:internal/bootstrap/switches/is_main_thread | 2 |
| node:os | 1.8 |
| inclusive, application modules | ms |
| --- | ---: |
### at 472 ms, 336 ms
| self time by module | ms |
| --- | ---: |
| (native) | 121.3 |
| wasm://wasm/00d1cb12 | 43 |
| node:internal/modules/cjs/loader | 40.5 |
| npm:next | 31.7 |
| npm:@prisma/client | 23.8 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 23.4 |
| node:internal/modules/esm/utils | 17.3 |
| npm:zod | 5.9 |
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 3 |
| node:vm | 1.7 |
| inclusive, application modules | ms |
| --- | ---: |
| src/instrumentation.ts | 95.8 |
| src/lib/db-session.ts | 47.3 |
| src/lib/process-type.ts | 27.1 |
| src/lib/boot/startup-state.ts | 25.5 |
| src/lib/boot/boot-steps.ts | 25.5 |
| src/lib/logging/background.ts | 25.5 |
| src/lib/logging/redact.ts | 13 |
| src/lib/logging/context.ts | 11 |
| src/lib/validations/measurement.ts | 10 |
| src/generated/prisma/internal/class.ts | 4.7 |
### at 811 ms, 225 ms
| self time by module | ms |
| --- | ---: |
| node:internal/modules/cjs/loader | 52.3 |
| (native) | 48.2 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 37 |
| npm:next | 22.2 |
| npm:zod | 12.5 |
| node:internal/modules/esm/utils | 8.7 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 5.6 |
| node:fs | 4.5 |
| node:internal/async_hooks | 3 |
| node:internal/modules/esm/translators | 2.6 |
| inclusive, application modules | ms |
| --- | ---: |
| src/app/api/health/route.ts | 62 |
| src/lib/api-errors.ts | 25.9 |
| src/lib/auth/secure-cookie.ts | 14.9 |
| src/lib/logging/background.ts | 13.4 |
| src/lib/sharing/acting-session.ts | 12.8 |
| src/lib/validations/measurement.ts | 11.9 |
| src/lib/db-session.ts | 11.3 |
| src/lib/export/environment-backup.ts | 11.3 |
| src/lib/export/full-backup-payload.ts | 11.3 |
| src/lib/jobs/job-failures.ts | 11.3 |
### at 1063 ms, 246 ms
| self time by module | ms |
| --- | ---: |
| (native) | 64.7 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 47.2 |
| node:internal/modules/cjs/loader | 31.6 |
| npm:luxon | 18 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 15.3 |
| npm:yaml | 15 |
| npm:zod | 10.2 |
| src/lib/i18n/server-catalogs.ts | 8.2 |
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 4.5 |
| npm:cron-parser | 4 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/jobs/reminder-worker.ts | 137.6 |
| src/lib/jobs/reminder/google-health-sync.ts | 78 |
| src/lib/whoop/sync.ts | 65.9 |
| src/instrumentation.ts | 53.8 |
| src/lib/insights/bucket-series.ts | 47.1 |
| src/lib/whoop/oauth-descriptor.ts | 47.1 |
| src/lib/whoop/sync-core.ts | 47.1 |
| src/lib/whoop/sync-body.ts | 47.1 |
| src/lib/import/unzip-export-xml.ts | 35.3 |
| src/lib/jobs/integration-status-null-probe.ts | 35.3 |
### at 1363 ms, 68 ms
| self time by module | ms |
| --- | ---: |
| node:internal/modules/cjs/loader | 29.2 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 6.7 |
| (native) | 5.9 |
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 4.5 |
| src/lib/daily/today-overview.ts | 4.1 |
| node:fs | 3 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 3 |
| node:internal/streams/writable | 1.5 |
| src/lib/jobs/reminder/insights-handlers.ts | 1.5 |
| npm:next | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/jobs/reminder-worker.ts | 62.5 |
| src/lib/jobs/reminder/insights-handlers.ts | 24.4 |
| src/lib/ai/prompts/arrival-reaction.ts | 12.8 |
| src/lib/daily/score-steady.ts | 11.3 |
| src/lib/dashboard/bands.ts | 7.1 |
| src/lib/dashboard/snapshot-read.ts | 5.8 |
| src/lib/jobs/data-arrival.ts | 4.5 |
| src/lib/daily/today-overview.ts | 4.1 |
| src/lib/daily/priority-item-key.ts | 4.1 |
| src/lib/ai/prompts/status-batch.ts | 3 |
### at 1545 ms, 57 ms
| self time by module | ms |
| --- | ---: |
| node:internal/modules/cjs/loader | 25.3 |
| (native) | 12.9 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 7.2 |
| node:fs | 4.2 |
| node:buffer | 1.5 |
| npm:zod | 1.5 |
| src/lib/jobs/pr-detection.ts | 1.5 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 1.5 |
| src/lib/jobs/measurement-maintenance.ts | 1.3 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/jobs/reminder-worker.ts | 55.3 |
| src/lib/jobs/reminder/ops-handlers.ts | 13 |
| src/lib/export/restore-backup.ts | 3 |
| src/lib/medications/ingredients/label-extract.ts | 3 |
| src/lib/environment/service.ts | 1.5 |
| src/lib/export/backup-blob.ts | 1.5 |
| src/lib/jobs/pr-detection.ts | 1.5 |
| src/lib/jobs/document-content-index-backfill.ts | 1.5 |
| src/lib/labs/ocr-extract.ts | 1.5 |
| src/lib/jobs/reminder/backup-handlers.ts | 1.5 |
### at 1820 ms, 114 ms
| self time by module | ms |
| --- | ---: |
| src/lib/tz/zone-offset.ts | 36 |
| npm:@prisma/client | 18.2 |
| src/lib/validations/source-priority.ts | 18.1 |
| (native) | 13.1 |
| src/lib/personal-records/pr-detection-worker.ts | 7.2 |
| node:buffer | 4.1 |
| src/lib/analytics/source-priority.ts | 3.7 |
| npm:zod | 2.3 |
| npm:pg | 2 |
| src/lib/tz/format.ts | 1.9 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 73.3 |
| src/lib/tz/format.ts | 40.7 |
| src/lib/measurements/consolidation-tz.ts | 40.7 |
| src/lib/tz/zone-offset.ts | 38.8 |
| src/lib/measurements/consolidation-base.ts | 38.1 |
| src/lib/analytics/source-priority.ts | 28.1 |
| src/lib/validations/source-priority.ts | 20.4 |
| src/lib/tz/intl-cache.ts | 1.5 |
### at 2407 ms, 50 ms
| self time by module | ms |
| --- | ---: |
| (native) | 18.9 |
| src/lib/validations/source-priority.ts | 14.1 |
| src/lib/analytics/source-priority.ts | 3.7 |
| src/lib/personal-records/pr-detection-worker.ts | 3 |
| npm:next | 1.8 |
| npm:%40prisma | 1.5 |
| src/lib/tz/format.ts | 1.5 |
| node:internal/async_local_storage/async_hooks | 1.5 |
| npm:pg | 1.5 |
| npm:@prisma/client | 1.3 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 26.6 |
| src/lib/analytics/source-priority.ts | 19.3 |
| src/lib/validations/source-priority.ts | 14.1 |
| src/lib/tz/format.ts | 3 |
| src/lib/measurements/consolidation-tz.ts | 3 |
| src/lib/measurements/consolidation-base.ts | 2.8 |
| src/lib/tz/zone-offset.ts | 1.5 |
### at 18192 ms, 59 ms
| self time by module | ms |
| --- | ---: |
| (native) | 15.5 |
| npm:@prisma/client | 13.9 |
| src/lib/validations/source-priority.ts | 11.8 |
| npm:pg | 3.6 |
| node:events | 3 |
| node:buffer | 2.5 |
| src/lib/measurements/consolidation-base.ts | 1.5 |
| src/lib/personal-records/pr-detection-worker.ts | 1.5 |
| node:internal/async_hooks | 1.5 |
| npm:pg-types | 1.4 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 18.9 |
| src/lib/analytics/source-priority.ts | 12.9 |
| src/lib/validations/source-priority.ts | 11.8 |
| src/lib/measurements/consolidation-base.ts | 4.5 |
| src/lib/tz/zone-offset.ts | 2.6 |
| src/lib/tz/format.ts | 2.6 |
| src/lib/measurements/consolidation-tz.ts | 2.6 |
### at 19914 ms, 65 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 18.1 |
| (native) | 12.3 |
| npm:@prisma/client | 11.6 |
| npm:next | 5.9 |
| src/lib/personal-records/pr-detection-worker.ts | 3 |
| src/lib/tz/format.ts | 3 |
| npm:pg | 2.6 |
| npm:pg-protocol | 2.5 |
| node:internal/async_hooks | 1.5 |
| src/lib/analytics/source-priority.ts | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 29.8 |
| src/lib/analytics/source-priority.ts | 22.6 |
| src/lib/validations/source-priority.ts | 18.1 |
| src/lib/tz/format.ts | 5.8 |
| src/lib/measurements/consolidation-tz.ts | 5.8 |
| src/lib/tz/zone-offset.ts | 2.8 |
| src/lib/measurements/consolidation-base.ts | 2.8 |
### at 20296 ms, 55 ms
| self time by module | ms |
| --- | ---: |
| (native) | 22.4 |
| npm:@prisma/client | 14.8 |
| node:buffer | 3 |
| npm:zod | 3 |
| src/lib/measurements/consolidation-base.ts | 3 |
| src/lib/tz/format.ts | 1.5 |
| node:internal/stream_base_commons | 1.5 |
| src/lib/analytics/source-priority.ts | 1.5 |
| src/lib/personal-records/pr-detection-worker.ts | 1.5 |
| npm:pg-protocol | 1.3 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 10.5 |
| src/lib/analytics/source-priority.ts | 6 |
| src/lib/validations/source-priority.ts | 3 |
| src/lib/measurements/consolidation-base.ts | 3 |
| src/lib/tz/format.ts | 1.5 |
| src/lib/measurements/consolidation-tz.ts | 1.5 |
### at 30289 ms, 69 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 18.3 |
| (native) | 12.9 |
| npm:@prisma/client | 8.8 |
| node:buffer | 6.8 |
| src/lib/personal-records/pr-detection-worker.ts | 4.5 |
| src/lib/tz/format.ts | 4 |
| npm:pg-protocol | 3 |
| npm:zod | 3 |
| node:timers | 1.5 |
| node:internal/async_hooks | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 33 |
| src/lib/analytics/source-priority.ts | 24.3 |
| src/lib/validations/source-priority.ts | 21.3 |
| src/lib/tz/format.ts | 5.6 |
| src/lib/measurements/consolidation-tz.ts | 5.6 |
| src/lib/measurements/consolidation-base.ts | 2.6 |
| src/lib/tz/zone-offset.ts | 1.5 |
### at 31004 ms, 68 ms
| self time by module | ms |
| --- | ---: |
| (native) | 17.8 |
| src/lib/validations/source-priority.ts | 14.9 |
| npm:@prisma/client | 7.6 |
| npm:next | 4.5 |
| node:internal/async_hooks | 4.5 |
| npm:pg-protocol | 4.1 |
| npm:pg | 3 |
| src/lib/tz/zone-offset.ts | 3 |
| node:buffer | 2.7 |
| src/lib/analytics/source-priority.ts | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 26.4 |
| src/lib/analytics/source-priority.ts | 19 |
| src/lib/validations/source-priority.ts | 16.2 |
| src/lib/measurements/consolidation-base.ts | 6.1 |
| src/lib/tz/format.ts | 5.8 |
| src/lib/measurements/consolidation-tz.ts | 5.8 |
| src/lib/tz/zone-offset.ts | 4.5 |
### at 35350 ms, 65 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 17 |
| npm:@prisma/client | 11.8 |
| (native) | 8.6 |
| node:buffer | 6.5 |
| src/lib/tz/format.ts | 4.5 |
| npm:pg-protocol | 3 |
| src/lib/personal-records/pr-detection-worker.ts | 3 |
| npm:%40prisma | 1.6 |
| npm:pg | 1.5 |
| src/lib/analytics/source-priority.ts | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 26 |
| src/lib/analytics/source-priority.ts | 20 |
| src/lib/validations/source-priority.ts | 17 |
| src/lib/tz/format.ts | 4.5 |
| src/lib/measurements/consolidation-tz.ts | 4.5 |
| src/lib/measurements/consolidation-base.ts | 3 |
### at 36452 ms, 57 ms
| self time by module | ms |
| --- | ---: |
| (native) | 20 |
| src/lib/validations/source-priority.ts | 16 |
| npm:@prisma/client | 7.3 |
| node:internal/per_context/primordials | 2.8 |
| npm:%40prisma | 1.5 |
| npm:next | 1.5 |
| src/lib/tz/zone-offset.ts | 1.5 |
| node:internal/async_hooks | 1.5 |
| node:internal/streams/readable | 1.5 |
| npm:pg-protocol | 1.2 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 21.2 |
| src/lib/validations/source-priority.ts | 16 |
| src/lib/analytics/source-priority.ts | 16 |
| src/lib/tz/format.ts | 2.5 |
| src/lib/measurements/consolidation-tz.ts | 2.5 |
| src/lib/measurements/consolidation-base.ts | 2.5 |
| src/lib/tz/zone-offset.ts | 1.5 |
### at 38744 ms, 56 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 13.3 |
| (native) | 9.9 |
| npm:@prisma/client | 9.2 |
| node:buffer | 4.7 |
| npm:pg-protocol | 4.3 |
| node:events | 1.9 |
| npm:pg | 1.6 |
| npm:%40prisma | 1.5 |
| npm:next | 1.5 |
| src/lib/tz/format.ts | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 21.8 |
| src/lib/analytics/source-priority.ts | 16 |
| src/lib/validations/source-priority.ts | 13.3 |
| src/lib/measurements/consolidation-base.ts | 4.3 |
| src/lib/tz/format.ts | 3 |
| src/lib/measurements/consolidation-tz.ts | 3 |
| src/lib/tz/zone-offset.ts | 1.4 |
### at 42458 ms, 50 ms
| self time by module | ms |
| --- | ---: |
| npm:@prisma/client | 13.5 |
| src/lib/validations/source-priority.ts | 10.1 |
| (native) | 7.6 |
| npm:pg-protocol | 3.2 |
| node:events | 3 |
| src/lib/personal-records/pr-detection-worker.ts | 3 |
| src/lib/tz/format.ts | 3 |
| npm:pg | 2.8 |
| node:internal/async_hooks | 1.5 |
| npm:%40prisma | 1.4 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 17.3 |
| src/lib/analytics/source-priority.ts | 12.7 |
| src/lib/validations/source-priority.ts | 10.1 |
| src/lib/tz/format.ts | 3 |
| src/lib/measurements/consolidation-tz.ts | 3 |
| src/lib/measurements/consolidation-base.ts | 1.5 |
| src/lib/jobs/job-lock.ts | 1.3 |
| src/lib/jobs/long-pass.ts | 1.3 |
| src/lib/jobs/run-job.ts | 1.3 |
| src/lib/jobs/job-observer.ts | 1.3 |
### at 45451 ms, 63 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 15.8 |
| npm:@prisma/client | 10.5 |
| (native) | 8.4 |
| src/lib/tz/format.ts | 5.3 |
| node:internal/async_local_storage/async_hooks | 4.4 |
| node:buffer | 3 |
| npm:pg | 3 |
| src/lib/personal-records/pr-detection-worker.ts | 3 |
| npm:zod | 3 |
| npm:%40prisma | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 31.7 |
| src/lib/analytics/source-priority.ts | 21.8 |
| src/lib/validations/source-priority.ts | 18.8 |
| src/lib/measurements/consolidation-base.ts | 5.3 |
| src/lib/tz/format.ts | 5.3 |
| src/lib/measurements/consolidation-tz.ts | 5.3 |
### at 56854 ms, 66 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 15 |
| npm:@prisma/client | 13.7 |
| (native) | 6.6 |
| node:buffer | 6.6 |
| npm:next | 6 |
| npm:pg | 4.5 |
| src/lib/personal-records/pr-detection-worker.ts | 4.1 |
| src/lib/tz/format.ts | 3.1 |
| node:internal/process/task_queues | 1.5 |
| node:internal/async_hooks | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 25.3 |
| src/lib/analytics/source-priority.ts | 18.1 |
| src/lib/validations/source-priority.ts | 16.5 |
| src/lib/tz/format.ts | 3.1 |
| src/lib/measurements/consolidation-tz.ts | 3.1 |
| src/lib/measurements/consolidation-base.ts | 3.1 |
### at 56923 ms, 52 ms
| self time by module | ms |
| --- | ---: |
| (native) | 20 |
| npm:@prisma/client | 10.1 |
| node:buffer | 3.7 |
| npm:pg | 3 |
| src/lib/tz/format.ts | 2.6 |
| npm:pg-protocol | 2.6 |
| src/lib/analytics/source-priority.ts | 2.6 |
| src/lib/personal-records/pr-detection-worker.ts | 1.5 |
| src/lib/tz/zone-offset.ts | 1.5 |
| npm:pg-types | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 9.3 |
| src/lib/analytics/source-priority.ts | 5.3 |
| src/lib/tz/format.ts | 4.1 |
| src/lib/measurements/consolidation-tz.ts | 4.1 |
| src/lib/measurements/consolidation-base.ts | 2.6 |
| src/lib/tz/zone-offset.ts | 1.5 |
| src/lib/validations/source-priority.ts | 1.1 |
### at 59508 ms, 56 ms
| self time by module | ms |
| --- | ---: |
| npm:@prisma/client | 15.7 |
| src/lib/validations/source-priority.ts | 9.7 |
| node:buffer | 7 |
| (native) | 6.1 |
| npm:pg-protocol | 4.5 |
| npm:next | 4.1 |
| src/lib/tz/format.ts | 3 |
| src/lib/analytics/source-priority.ts | 3 |
| node:internal/async_hooks | 1.5 |
| src/lib/measurements/consolidation-base.ts | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 21.7 |
| src/lib/analytics/source-priority.ts | 15.8 |
| src/lib/validations/source-priority.ts | 9.7 |
| src/lib/tz/format.ts | 5.9 |
| src/lib/measurements/consolidation-tz.ts | 5.9 |
| src/lib/measurements/consolidation-base.ts | 5.9 |
| src/lib/tz/zone-offset.ts | 2.9 |
### at 61315 ms, 62 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 20.1 |
| npm:@prisma/client | 14.1 |
| (native) | 8.2 |
| npm:pg | 4.5 |
| node:buffer | 3.1 |
| src/lib/personal-records/pr-detection-worker.ts | 3 |
| src/lib/measurements/consolidation-base.ts | 3 |
| npm:pg-protocol | 1.5 |
| npm:next | 1.5 |
| src/lib/analytics/source-priority.ts | 1.4 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 30.3 |
| src/lib/analytics/source-priority.ts | 22.8 |
| src/lib/validations/source-priority.ts | 21.4 |
| src/lib/measurements/consolidation-base.ts | 4.5 |
| src/lib/tz/zone-offset.ts | 1.5 |
| src/lib/tz/format.ts | 1.5 |
| src/lib/measurements/consolidation-tz.ts | 1.5 |
### at 72986 ms, 57 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 19.1 |
| npm:@prisma/client | 10.2 |
| (native) | 6.6 |
| npm:pg-protocol | 5.5 |
| npm:next | 3 |
| src/lib/analytics/source-priority.ts | 3 |
| npm:pg | 2.5 |
| src/lib/personal-records/pr-detection-worker.ts | 1.5 |
| node:internal/async_local_storage/async_hooks | 1.5 |
| src/lib/tz/format.ts | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 29.6 |
| src/lib/analytics/source-priority.ts | 23.5 |
| src/lib/validations/source-priority.ts | 19.1 |
| src/lib/measurements/consolidation-base.ts | 4.6 |
| src/lib/tz/format.ts | 4.5 |
| src/lib/measurements/consolidation-tz.ts | 4.5 |
| src/lib/tz/zone-offset.ts | 3 |
### at 73523 ms, 51 ms
| self time by module | ms |
| --- | ---: |
| src/lib/validations/source-priority.ts | 12 |
| npm:next | 8.9 |
| npm:@prisma/client | 6 |
| (native) | 4.5 |
| src/lib/analytics/source-priority.ts | 4.2 |
| node:buffer | 4 |
| npm:pg | 2.1 |
| npm:pg-protocol | 1.5 |
| src/lib/personal-records/pr-detection-worker.ts | 1.5 |
| src/lib/tz/zone-offset.ts | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 27.8 |
| src/lib/analytics/source-priority.ts | 17.7 |
| src/lib/validations/source-priority.ts | 13.5 |
| src/lib/measurements/consolidation-base.ts | 8.6 |
| src/lib/tz/format.ts | 7.1 |
| src/lib/measurements/consolidation-tz.ts | 7.1 |
| src/lib/tz/zone-offset.ts | 6 |
### at 75393 ms, 100 ms
| self time by module | ms |
| --- | ---: |
| (native) | 32.6 |
| npm:@prisma/client | 17.2 |
| src/lib/validations/source-priority.ts | 14.6 |
| npm:next | 7.5 |
| src/lib/personal-records/pr-detection-worker.ts | 6.1 |
| src/lib/tz/zone-offset.ts | 5.6 |
| npm:pg-protocol | 4.1 |
| npm:pg | 2.5 |
| src/lib/tz/format.ts | 2.3 |
| node:internal/async_local_storage/async_hooks | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 34.6 |
| src/lib/analytics/source-priority.ts | 24.3 |
| src/lib/validations/source-priority.ts | 16.1 |
| src/lib/tz/format.ts | 10.9 |
| src/lib/measurements/consolidation-tz.ts | 10.9 |
| src/lib/tz/zone-offset.ts | 8.6 |
| src/lib/measurements/consolidation-base.ts | 5.7 |
### at 78085 ms, 54 ms
| self time by module | ms |
| --- | ---: |
| src/lib/tz/zone-offset.ts | 26.6 |
| src/lib/validations/source-priority.ts | 13.5 |
| npm:@prisma/client | 4.2 |
| (native) | 4.1 |
| node:internal/async_local_storage/async_hooks | 1.5 |
| src/lib/tz/format.ts | 1.5 |
| src/lib/tz/intl-cache.ts | 1.5 |
| npm:pg | 1.1 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/personal-records/pr-detection-worker.ts | 44.7 |
| src/lib/tz/format.ts | 29.6 |
| src/lib/measurements/consolidation-tz.ts | 29.6 |
| src/lib/measurements/consolidation-base.ts | 29.6 |
| src/lib/tz/zone-offset.ts | 28.1 |
| src/lib/analytics/source-priority.ts | 15 |
| src/lib/validations/source-priority.ts | 13.5 |
| src/lib/tz/intl-cache.ts | 1.5 |
## Self time by module until the worker is ready
| module | ms |
| --- | ---: |
| (native) | 853 |
| npm:@prisma/client | 295.9 |
| node:internal/modules/cjs/loader | 248.7 |
| src/lib/validations/source-priority.ts | 210.3 |
| npm:next | 188.4 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 121.5 |
| src/lib/tz/zone-offset.ts | 83.2 |
| wasm://wasm/00d1cb12 | 81.4 |
| npm:pg | 78.5 |
| node:buffer | 59.1 |
| src/lib/tz/format.ts | 55 |
| npm:zod | 50.7 |
| src/lib/personal-records/pr-detection-worker.ts | 48 |
| npm:pg-protocol | 37 |
| node:internal/bootstrap/realm | 32 |
| src/lib/analytics/source-priority.ts | 31.7 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 27.7 |
| node:internal/modules/esm/utils | 26 |
| npm:luxon | 22.1 |
| node:internal/async_hooks | 21.7 |
| node:internal/async_local_storage/async_hooks | 21.2 |
| node:fs | 18.8 |
| npm:pg-boss | 18.3 |
| npm:pg-pool | 18.1 |
| npm:yaml | 15 |
## Heap snapshot after 300 s idle (live heap after a full GC)
159.5 MB in 1302659 nodes; module source strings over 50 KB: 39.8 MB.
- parsed locale catalogs: 2 objects, 1.6 MB retained
| node type | MB |
| --- | ---: |
| string | 67.5 |
| array | 26.6 |
| code | 19.2 |
| object | 15.9 |
| hidden | 9.2 |
| native | 8 |
| object shape | 5.9 |
| closure | 5.2 |
| concatenated string | 1.3 |
| number | 0.5 |
| sliced string | 0.2 |
| regexp | 0.2 |
| synthetic | 0 |
| symbol | 0 |
| bigint | 0 |
| constructor | MB | count |
| --- | ---: | ---: |
| array:(object elements) | 20.8 | 25651 |
| object:Object | 11.4 | 293893 |
| native:system / JSArrayBufferData | 7.5 | 170 |
| array:(object properties) | 3.3 | 6517 |
| array: | 2.5 | 10169 |
| closure: | 1.6 | 29121 |
| object:system / Context | 1.6 | 28886 |
| closure:native_bind | 1 | 20804 |
| object:Array | 0.6 | 20938 |
| object:Date1 | 0.5 | 5332 |
| object:a | 0.4 | 4438 |
| native:Node / histogram | 0.4 | 1 |
| object:d | 0.3 | 4425 |
| closure:get | 0.3 | 4732 |
| object:Set | 0.2 | 5670 |
| object:Module | 0.2 | 1963 |
| closure:set | 0.2 | 3428 |
| closure:n | 0.1 | 1572 |
| closure:i | 0.1 | 1578 |
| object:Promise | 0.1 | 1341 |
| largest retained subtrees | MB | retaining path |
| --- | ---: | --- |
| object:yr | 34.6 | [1] > [10] > 22 > extension > global > prisma > [1] > _engine |
| object:system / Context | 7.4 | Node / PrincipalRealm > base_object_list > Node / BaseObjectList > [25] > Node / ModuleWrap > native_to_javascript > ModuleWrap > 2 > [4] > context |
| object:Promise | 5.9 | Object > executor > e > #r > gr > transactions > Map > table > > 6 > closing |
| object:Module | 5.6 | [1] > [10] > 99 > process > getBuiltinModule > getBuiltinModule > context > system / Context > $Module > Module > _cache > |
| code:.next/server/chu | 5.6 | Module > _cache > Object > .next/server/chunks/_1kwfnjg._.js > Module > exports > Array > [1] > > shared > script |
| code:.next/server/chu | 5.6 | global > prisma > [1] > t > _engineConfig > Object > compilerWasm > Object > getRuntime > getRuntime > shared > script |
| object:Generator | 4.4 | [6] > principal_realm > Node / PrincipalRealm > base_object_list > Node / BaseObjectList > [26] > Node / ModuleWrap > native_to_javascript > ModuleWrap > 2 > [4 |
| string:AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/ | 4.4 | [1] > [21] > 60025 |
| object:Object | 3.4 | system / Context > $Module > Module > _cache > Object > .next/server/chunks/[turbopack]_runtime.js > Module > export |
| object:global | 2.6 | [1] > [10] > 22 > extension |
| largest strings | MB |
| --- | ---: |
| module.exports=[234494,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=> | 5.6 |
| module.exports=[17856,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=>i | 5.6 |
| module.exports=[17856,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=>i | 5.6 |
| const wasm = "AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/fwBgBX9/f39/AGAB | 4.4 |
| AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/fwBgBX9/f39/AGABfwF/YAZ/f39/f3 | 4.4 |
| lliADOAUngIEAACgKwAgBgAAoSsAIAcAAKErACAIAACiKwAgCQAAoysAIAoAAKQrACALAAClKwAgDAAApisAIA0AAK | 1.9 |
| (()=>{var e,t,r={"./dist/build/webpack/alias/react-dom-server.js"(e,t,r){"use strict";var | 1.3 |
| module.exports=[842865,(e,t,r)=>{let i=e.r(522734);t.exports=function(e){return e?/-----BE | 1.2 |
| module.exports=[842865,(e,t,r)=>{let i=e.r(522734);t.exports=function(e){return e?/-----BE | 1.2 |
| module.exports=[799092,(e,t,n)=>{"use strict";Object.defineProperty(n,"__esModule",{value: | 1.1 |
| // v1.4.34 web-freeze. Schema is locked additive-only until iOS launch // + v1.5.0 marker. | 1.1 |
| module.exports=[971743,(A,e,t)=>{"use strict";let r=A.r(57328),{addAbortListener:s}=A.r(68 | 1.1 |
| module.exports=[835717,(A,e,t)=>{"use strict";let r=A.r(57328),{addAbortListener:s}=A.r(68 | 1 |
| module.exports=[767189,e=>e.a(async(t,a)=>{try{var n=e.i(64105),r=e.i(563616),i=t([n]);[n] | 0.8 |
| module.exports=[594108,806176,674750,98746,t=>{"use strict";var i;let r;function n(t){retu | 0.7 |
+28
View File
@@ -0,0 +1,28 @@
# v1.45.0: boot and idle memory
`scripts/bench/boot-profile.ts`, production build, every role, heap ceiling
792 MB, against the seeded account, 300 s idle. `v1.45.0-before-boot` is the
v1.44 runtime, `v1.45.0-boot-after` the branch after the boot changes and
before the lazy match indexes, `v1.45.0-boot-final` the final tree.
| | before | final |
| --- | ---: | ---: |
| longest main-thread busy stretch, application code | 753 ms (worker graph, one block) | 336 ms (query compiler start, 324 ms before as well) |
| longest stretch loading the worker graph | 753 ms | 246 ms (one registrar) |
| live heap after full collections, idle | 162 to 172 MB | 142 to 153 MB |
| heap snapshot after 300 s idle | 172.3 MB | 159.5 MB |
| parsed locale catalogs in the heap | 32 objects, 14.1 MB | 2 objects, 1.6 MB |
| catalog chunk sources in the heap | 2 x 9.7 MB | none |
| module source strings over 50 KB | 44.2 MB | 39.8 MB |
| event-loop stalls logged (500 ms threshold) | 0 (monitor started late) | 0 (monitor first) |
Time to the first health answer and to a ready worker moved with the host's
load (1.6 s / 5.4 s before at load 9, 1.3 s / 3.2 s after at load 6) and is
not claimed as a result.
The rest of the 150 MB is the runtime itself: the query compiler (about
50 MB: its engine object, its WebAssembly source and chunk, twice), compiled
code (19 MB) and the remaining module sources. The used heap between full
collections stayed under 240 MB in both runs; the beta's 400 MB plateau is
garbage awaiting a collection under a larger limit plus the server caches of
real users, not a baseline the process cannot shed.
+22
View File
@@ -0,0 +1,22 @@
# v1.45.0: request fan-out
Same runs as `v1.45.0-read-path.md`.
| request | statements before | statements after | p50 ms before / after |
| --- | ---: | ---: | ---: |
| /api/auth/me | 19 | 14 | 9 / 8 |
| day view, cold | 49 | 48 | 77 / 68 |
- `/api/auth/me` read the AppSettings singleton six times (MFA policy,
Assistant switches, module availability, provider presence, AI usage
bounds, reasoning controls); one memoised read per request now serves all
six (`src/lib/settings/operator-row.ts`). On the small budget fixture it
went from 18 to 13 statements. The remaining reads are the session, the
grant, cycle profile, onboarding, consent, coach usage and five user reads
with different column sets.
- The day view's record sections run six at a time instead of about
twenty-five at once, so one day never holds more than a third of the pool.
The statement count is the same; what changes is the peak of connections.
- Keyset pagination for `/api/measurements` was not done: a cursor needs a
new request parameter and a response field, a contract change for the
native client.
+254
View File
@@ -0,0 +1,254 @@
{
"meta": {
"label": "v1.45.0-final",
"commit": "a57fa8335",
"date": "2026-10-22T06:52:09.539Z",
"node": "v22.23.2",
"postgres": "16.15",
"cpus": 14,
"loadAverage": [
6.8,
8.9,
14.5
],
"measurements": 1265561,
"iterations": 20,
"warmup": 3,
"heapMb": 792,
"idleStatementsPerSecond": 0,
"batch5k": {
"totalMs": 4448,
"totalStatements": 504
}
},
"results": [
{
"name": "/api/auth/me",
"mode": "cold",
"request": "GET /api/auth/me",
"n": 20,
"p50Ms": 8.3,
"p95Ms": 27.2,
"maxMs": 221,
"statementsMedian": 14,
"statementsMax": 14,
"failures": 0
},
{
"name": "/api/auth/me",
"mode": "warm",
"request": "GET /api/auth/me",
"n": 20,
"p50Ms": 7.9,
"p95Ms": 10.1,
"maxMs": 17.1,
"statementsMedian": 14,
"statementsMax": 17,
"failures": 0
},
{
"name": "dashboard summary",
"mode": "cold",
"request": "GET /api/dashboard/summary",
"n": 20,
"p50Ms": 86.6,
"p95Ms": 100.8,
"maxMs": 126.8,
"statementsMedian": 30,
"statementsMax": 31,
"failures": 0
},
{
"name": "dashboard summary",
"mode": "warm",
"request": "GET /api/dashboard/summary",
"n": 20,
"p50Ms": 9.2,
"p95Ms": 17.1,
"maxMs": 18.4,
"statementsMedian": 6,
"statementsMax": 6,
"failures": 0
},
{
"name": "dashboard snapshot",
"mode": "cold",
"request": "GET /api/dashboard/snapshot",
"n": 20,
"p50Ms": 545.3,
"p95Ms": 592.5,
"maxMs": 606.3,
"statementsMedian": 153,
"statementsMax": 156,
"failures": 0
},
{
"name": "dashboard snapshot",
"mode": "warm",
"request": "GET /api/dashboard/snapshot",
"n": 20,
"p50Ms": 8.1,
"p95Ms": 17.9,
"maxMs": 21.9,
"statementsMedian": 10,
"statementsMax": 10,
"failures": 0
},
{
"name": "series pulse 30 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=30",
"n": 20,
"p50Ms": 18.7,
"p95Ms": 27.2,
"maxMs": 36.1,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "series pulse 30 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=30",
"n": 20,
"p50Ms": 15.9,
"p95Ms": 21.8,
"maxMs": 26,
"statementsMedian": 8,
"statementsMax": 8,
"failures": 0
},
{
"name": "series pulse 365 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=365",
"n": 20,
"p50Ms": 40.7,
"p95Ms": 55.6,
"maxMs": 56.2,
"statementsMedian": 7,
"statementsMax": 8,
"failures": 0
},
{
"name": "series pulse 365 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=365",
"n": 20,
"p50Ms": 38.3,
"p95Ms": 59.6,
"maxMs": 88.6,
"statementsMedian": 7,
"statementsMax": 7,
"failures": 0
},
{
"name": "series pulse 3650 d",
"mode": "cold",
"request": "GET /api/measurements/series?kind=pulse&days=3650",
"n": 20,
"p50Ms": 334.9,
"p95Ms": 348.5,
"maxMs": 351.2,
"statementsMedian": 7,
"statementsMax": 7,
"failures": 0
},
{
"name": "series pulse 3650 d",
"mode": "warm",
"request": "GET /api/measurements/series?kind=pulse&days=3650",
"n": 20,
"p50Ms": 328.1,
"p95Ms": 346,
"maxMs": 348.9,
"statementsMedian": 7,
"statementsMax": 8,
"failures": 0
},
{
"name": "day view",
"mode": "cold",
"request": "GET /api/day/2026-10-21",
"n": 20,
"p50Ms": 68.4,
"p95Ms": 102.6,
"maxMs": 104.7,
"statementsMedian": 48,
"statementsMax": 49,
"failures": 0
},
{
"name": "day view",
"mode": "warm",
"request": "GET /api/day/2026-10-21",
"n": 20,
"p50Ms": 46.4,
"p95Ms": 66.1,
"maxMs": 72.6,
"statementsMedian": 47,
"statementsMax": 47,
"failures": 0
},
{
"name": "insights health status",
"mode": "cold",
"request": "GET /api/insights/health-status",
"n": 20,
"p50Ms": 156,
"p95Ms": 190,
"maxMs": 197.7,
"statementsMedian": 67,
"statementsMax": 67,
"failures": 0
},
{
"name": "insights health status",
"mode": "warm",
"request": "GET /api/insights/health-status",
"n": 20,
"p50Ms": 173.6,
"p95Ms": 1163.4,
"maxMs": 1419.4,
"statementsMedian": 67,
"statementsMax": 67,
"failures": 0
},
{
"name": "insights correlations",
"mode": "cold",
"request": "GET /api/insights/correlations",
"n": 20,
"p50Ms": 106.1,
"p95Ms": 229.1,
"maxMs": 383.3,
"statementsMedian": 48,
"statementsMax": 49,
"failures": 0
},
{
"name": "insights correlations",
"mode": "warm",
"request": "GET /api/insights/correlations",
"n": 20,
"p50Ms": 18.7,
"p95Ms": 626.6,
"maxMs": 1136.5,
"statementsMedian": 7,
"statementsMax": 7,
"failures": 0
},
{
"name": "batch ingest 5k (per 500)",
"mode": "write",
"request": "POST /api/measurements/batch ×10",
"n": 10,
"p50Ms": 185.7,
"p95Ms": 1269.2,
"maxMs": 1269.2,
"statementsMedian": 50,
"statementsMax": 53,
"failures": 0
}
]
}
+25
View File
@@ -0,0 +1,25 @@
# Benchmark v1.45.0-final
Commit a57fa8335 with the working tree of the final commits, 2026-10-22T06:52:09.539Z. 1265561 measurement rows on the bench account, 20 measured requests per row after 3 warm-up requests, one at a time. Server: production build, web process, heap ceiling 792 MB. Postgres 16.15, 14 cores, load average 6.8,8.9,14.5 at the end. Idle background: 0 statements/s.
| Request | Mode | n | p50 ms | p95 ms | max ms | statements (median) | statements (max) |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
| /api/auth/me | cold | 20 | 8.3 | 27.2 | 221 | 14 | 14 |
| /api/auth/me | warm | 20 | 7.9 | 10.1 | 17.1 | 14 | 17 |
| dashboard summary | cold | 20 | 86.6 | 100.8 | 126.8 | 30 | 31 |
| dashboard summary | warm | 20 | 9.2 | 17.1 | 18.4 | 6 | 6 |
| dashboard snapshot | cold | 20 | 545.3 | 592.5 | 606.3 | 153 | 156 |
| dashboard snapshot | warm | 20 | 8.1 | 17.9 | 21.9 | 10 | 10 |
| series pulse 30 d | cold | 20 | 18.7 | 27.2 | 36.1 | 8 | 8 |
| series pulse 30 d | warm | 20 | 15.9 | 21.8 | 26 | 8 | 8 |
| series pulse 365 d | cold | 20 | 40.7 | 55.6 | 56.2 | 7 | 8 |
| series pulse 365 d | warm | 20 | 38.3 | 59.6 | 88.6 | 7 | 7 |
| series pulse 3650 d | cold | 20 | 334.9 | 348.5 | 351.2 | 7 | 7 |
| series pulse 3650 d | warm | 20 | 328.1 | 346 | 348.9 | 7 | 8 |
| day view | cold | 20 | 68.4 | 102.6 | 104.7 | 48 | 49 |
| day view | warm | 20 | 46.4 | 66.1 | 72.6 | 47 | 47 |
| insights health status | cold | 20 | 156 | 190 | 197.7 | 67 | 67 |
| insights health status | warm | 20 | 173.6 | 1163.4 | 1419.4 | 67 | 67 |
| insights correlations | cold | 20 | 106.1 | 229.1 | 383.3 | 48 | 49 |
| insights correlations | warm | 20 | 18.7 | 626.6 | 1136.5 | 7 | 7 |
| batch ingest 5k (per 500) | write | 10 | 185.7 | 1269.2 | 1269.2 | 50 | 53 |
@@ -0,0 +1,45 @@
# v1.45.0: read path
Request benchmark on the seeded account (1,265,561 rows), production build,
web process, heap ceiling 792 MB, 20 requests per row, one at a time.
`v1.45.0-before` is the v1.44 runtime (1915f9152), `v1.45.0-final` this
branch; both on the same machine within the hour, load average 7 to 30
(other work on the host), so latency compares within a factor of noise and
statements exactly. Cold means every request follows a one-row manual sync.
| request | before p50 / p95 ms | after p50 / p95 ms | statements before / after |
| --- | ---: | ---: | ---: |
| dashboard snapshot, cold | 1482 / 1526 | 545 / 593 | 158 / 153 |
| dashboard summary, cold | 170 / 221 | 87 / 101 | 31 / 30 |
| series pulse 3650 d, cold | 1627 / 1665 | 335 / 349 | 8 / 7 |
| series pulse 3650 d, warm | 2091 / 2435 | 328 / 346 | 8 / 7 |
| series pulse 365 d, cold | 184 / 193 | 41 / 56 | 8 / 7 |
| series pulse 30 d, cold | 24 / 31 | 19 / 27 | 8 / 8 |
| insights health status, cold | 245 / 401 | 156 / 190 | 68 / 67 |
What moved them:
- **JIT off for app sessions.** Postgres compiled every fold whose estimated
cost passed `jit_above_cost`; on the ten-year pulse pick that was 100 to
140 ms of a 220 ms statement (`EXPLAIN ANALYZE`: 395 ms with JIT, 220 ms
without).
- **The canonical-source pick groups first.** `DISTINCT ON` now sorts the
distinct `(type, day, source)` triples instead of every raw reading (the
ten-year pulse pick sorted 260,000 rows and spilled to disk); 220 ms to
175 ms on its own. Parity: `tests/integration/canonical-source-pick-parity.test.ts`
runs the old statement beside the new one over five reader shapes and two
ladders on a fixture with overlapping and off-ladder sources, tombstones,
empty days and readings around midnight; identical rows.
- **The dense series reads its rows once.** Buckets and strip figures come
from one statement over a materialised window instead of two statements
that each picked the canonical source and scanned the readings. Parity:
`tests/integration/dense-series-read-parity.test.ts`, the two old
statements as reference, pulse (two ranked sources), glucose and oxygen
saturation, day and hour grains, UTC, Europe/Berlin, Asia/Kolkata and
America/New_York; identical buckets and figures (relative 1e-9).
- **Closed blocks survive a sync.** The slice's pre-fold remainder (every
reading older than five years, the snapshot's largest statement at 357 ms)
and the day view's past history blocks are cached under a key that names
the instant their readings end before; a sync of newer readings no longer
drops them (`src/lib/cache/closed-block.ts`). The values are the same
statement's; only how long they are kept changed.
@@ -0,0 +1,28 @@
# v1.45.0: workout duplicate window
A correctness change, not a speed one; the request benchmark does not read
workouts, so there is no latency row for it.
Before: two copies of one session were collapsed only when both starts fell
in the same fixed five-minute clock slot (`floor(start / 5 min)`). A pair 30 s
apart that straddled a slot boundary stayed two workouts in the list, the
batch write, the calendar, strain and the Coach.
After: per user and sport, sorted by start, a workout joins the open cluster
while it starts less than five minutes after the cluster's first start.
Proof (`src/lib/measurements/__tests__/pick-canonical-workout-rows.property.test.ts`,
against the previous picker kept as `pick-canonical-workout-rows.legacy.ts`):
| property | inputs |
| --- | --- |
| same rows as the fixed slots wherever no pair within five minutes straddles a slot boundary, three ladders | 4,000 generated inputs, over 1,000 comparable |
| same strain impulses as before on those inputs | 2,000 generated inputs, over 500 comparable |
| two sources' copies collapse at any offset under five minutes (the old picker missed a share) | 3,000 random offsets |
| five minutes or more apart never collapse | 2,000 |
| a uniform shift of every start keeps the picked set | 1,500 |
| a row of workouts four minutes apart does not chain | fixed case |
One existing case changed its expectation: three runs at 08:04, 08:08 and
08:12 used to stay three because they sat in three slots; 08:08 is now the
08:04 run from a lower-ranked source (four minutes apart), and 08:12 stays.
@@ -0,0 +1,26 @@
# v1.45.0: write path
Same runs as `v1.45.0-read-path.md`.
| request | before p50 ms | after p50 ms | statements before / after |
| --- | ---: | ---: | ---: |
| batch ingest, 500 fresh rows per request | 166 | 186 | 53 / 50 |
Fresh rows were already set-based (v1.44); the benchmark's ingest is all
fresh rows, so it shows no gain, as expected. The changes are for re-posts:
- **Unchanged `stats:` re-posts** write nothing and start nothing. One read
over the batch's `stats:` ids replaces a reconciler transaction (about ten
round trips) per row, and an all-unchanged batch no longer refolds, enqueues
rollups or PR detection, or marks caches stale. The per-entry status stays
`updated` and the summary's `updated` count is unchanged.
`tests/integration/routes/measurements-batch-unchanged-stats.test.ts`: four
unchanged re-posts take at most 12 statements in total (before: about 40)
with zero rollup, invalidation and PR calls; a changed value, a changed
device or a tombstoned row still goes through the reconciler.
- **Apple Health re-import** skips the UPDATE of a row stored with exactly the
values it would write; one read per chunk instead of one UPDATE per row.
`tests/integration/apple-health-reimport-unchanged.test.ts`: a re-import of
20 stored rows reports the same per-type counts and sends no UPDATE.
- **Narrower invalidation.** The batch route tells the cache how far back it
wrote (`touchedFrom`), so closed blocks of older readings stay.
+478
View File
@@ -0,0 +1,478 @@
/**
* Request benchmark against a seeded account: latency (p50, p95, max) and
* SQL statements per request for the hot read and write paths.
*
* DATABASE_URL=postgresql://… API_TOKEN_HMAC_KEY=… ENCRYPTION_KEYS=… \
* ENCRYPTION_ACTIVE_KEY_ID=… pnpm dlx tsx scripts/bench/run.ts \
* [--user bench-large] [--iterations 20] [--warmup 3] \
* [--base-url http://127.0.0.1:3100] [--out scripts/bench/results/<label>]
*
* See docs/contributing/benchmarks.md for the whole setup (a throwaway
* Postgres with pg_stat_statements, migrations, the seed, a production
* build). Never point it at a database that holds real data: it mints a
* token for the bench user and writes measurements under it.
*
* Without `--base-url` the script starts the production build itself
* (`next start` on `.next`) as a web-only process under the production heap
* ceiling, and stops it at the end.
*
* Statements are counted with pg_stat_statements: the sum of `calls` over
* the database, read before and after each request, once no statement is
* in flight and the count has stopped moving (so fire-and-forget writes a
* request starts are counted with it). The bench's own reads of the two
* views are excluded. Requests run one at a time, so nothing else is in the
* delta except the server's idle background, which is measured first and
* reported. Before the measurement the build boots once with every role, so
* the worker creates its queues as it does in production; whatever that
* boot scheduled is dropped.
*
* Each read runs twice: cold, with the user's caches evicted before every
* request by a one-row manual-sync ingest (excluded from the numbers), so
* the request rebuilds what it serves; and warm, back to back, which is
* what a cache hit costs. The batch ingest posts 5,000 fresh rows as ten
* requests of 500, the route's per-request limit, and removes them again.
*
* Output: `<out>.json` and `<out>.md`. Exit 2 on any non-2xx response.
*/
import { execSync, spawn, type ChildProcess } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { cpus, loadavg } from "node:os";
import { dirname, join } from "node:path";
import { Pool } from "pg";
import { issueApiToken } from "@/lib/auth/issue-token";
import { prisma } from "@/lib/db";
function arg(name: string, fallback: string): string {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1]! : fallback;
}
const USER_ID = arg("user", "bench-large");
const ITERATIONS = Number(arg("iterations", "20"));
const WARMUP = Number(arg("warmup", "3"));
const PORT = Number(arg("port", "3100"));
const BASE_URL = arg("base-url", "");
const OUT = arg("out", join("scripts", "bench", "results", "latest"));
const HEAP_MB = Number(arg("heap-mb", "792"));
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 1 });
async function statementCount(): Promise<number> {
const { rows } = await pool.query<{ n: string | null }>(
`SELECT sum(calls)::bigint AS n FROM pg_stat_statements
WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
AND query NOT ILIKE '%pg_stat_statements%'
AND query NOT ILIKE '%pg_stat_activity%'`,
);
return Number(rows[0]?.n ?? 0);
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/**
* Wait until the server has no statement in flight and the counter holds
* still for 150 ms (or 10 s pass), then read it. pg_stat_statements counts a
* statement when it ends, so a long fold in a background refresh would look
* settled by the counter alone; pg_stat_activity says it is still running.
*/
async function settledCount(): Promise<number> {
let last = -1;
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
const { rows } = await pool.query<{ active: string }>(
`SELECT count(*)::int AS active FROM pg_stat_activity
WHERE datname = current_database() AND pid <> pg_backend_pid()
AND state NOT IN ('idle')`,
);
const now = await statementCount();
if (Number(rows[0]?.active ?? 0) === 0 && now === last) return now;
last = now;
await sleep(150);
}
return last;
}
function percentile(sorted: number[], p: number): number {
if (sorted.length === 0) return 0;
const i = Math.ceil((p / 100) * sorted.length) - 1;
return sorted[Math.min(sorted.length - 1, Math.max(0, i))]!;
}
interface Sample {
ms: number;
statements: number;
status: number;
}
interface ScenarioResult {
name: string;
mode: "cold" | "warm" | "write";
request: string;
n: number;
p50Ms: number;
p95Ms: number;
maxMs: number;
statementsMedian: number;
statementsMax: number;
failures: number;
}
let token = "";
const failures: string[] = [];
async function call(
method: string,
path: string,
body?: unknown,
): Promise<Sample> {
const before = await settledCount();
const started = performance.now();
const res = await fetch(`${base()}${path}`, {
method,
headers: {
authorization: `Bearer ${token}`,
...(body ? { "content-type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
await res.arrayBuffer();
const ms = performance.now() - started;
const after = await settledCount();
if (!res.ok) failures.push(`${method} ${path}: ${res.status}`);
return { ms, statements: after - before, status: res.status };
}
function summarise(
name: string,
mode: ScenarioResult["mode"],
request: string,
samples: Sample[],
): ScenarioResult {
const ms = samples.map((s) => s.ms).sort((a, b) => a - b);
const st = samples.map((s) => s.statements).sort((a, b) => a - b);
const r = (n: number) => Math.round(n * 10) / 10;
return {
name,
mode,
request,
n: samples.length,
p50Ms: r(percentile(ms, 50)),
p95Ms: r(percentile(ms, 95)),
maxMs: r(ms[ms.length - 1] ?? 0),
statementsMedian: percentile(st, 50),
statementsMax: st[st.length - 1] ?? 0,
failures: samples.filter((s) => s.status >= 300).length,
};
}
/**
* The per-user rate limits would refuse a benchmark long before a person
* meets them (one run sends several hundred requests in minutes), so the
* bench user's buckets are cleared before each scenario. The limiter's own
* cost stays in every count.
*/
async function resetRateLimits(): Promise<void> {
await pool.query(`DELETE FROM rate_limits WHERE key LIKE '%:' || $1`, [
USER_ID,
]);
}
let bustCounter = 0;
/**
* Evict the user's caches with a real change, outside the numbers: a one-row
* ingest marked as a manual sync, which hard-evicts instead of marking
* stale, so the next read is a miss and rebuilds in the request.
*/
async function bust(anchorDay: string): Promise<void> {
bustCounter += 1;
const res = await fetch(`${base()}/api/measurements/batch`, {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
"content-type": "application/json",
},
body: JSON.stringify({
syncTrigger: "manual",
entries: [
{
hkIdentifier: "HKQuantityTypeIdentifierFlightsClimbed",
value: 10 + (bustCounter % 2),
unit: "count",
startDate: `${anchorDay}T08:00:00.000Z`,
endDate: `${anchorDay}T18:00:00.000Z`,
externalId: `stats:HKQuantityTypeIdentifierFlightsClimbed:${anchorDay}`,
},
],
}),
});
const body = (await res.json()) as {
data?: { inserted?: number; updated?: number };
};
// A bust that wrote nothing evicted nothing, and every "cold" number
// after it would be a cache hit.
if ((body.data?.inserted ?? 0) + (body.data?.updated ?? 0) !== 1) {
throw new Error(`cache bust wrote no row: ${JSON.stringify(body)}`);
}
}
function base(): string {
return BASE_URL || `http://127.0.0.1:${PORT}`;
}
async function waitForHealth(
timeoutMs: number,
server: ChildProcess | null,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (server && server.exitCode !== null) {
throw new Error(`the server exited with code ${server.exitCode}`);
}
try {
const res = await fetch(`${base()}/api/health`);
if (res.ok) return;
} catch {
// not listening yet
}
await sleep(500);
}
throw new Error(`server at ${base()} did not answer /api/health`);
}
function startServer(role: "web" | "all"): ChildProcess {
// `next start` rather than the standalone server: the standalone tree
// lacks the externalised runtime packages the image installs separately
// (see the Dockerfile), while the checkout has them. It serves the same
// build output.
const child = spawn(
process.execPath,
[
"node_modules/next/dist/bin/next",
"start",
"-p",
String(PORT),
"-H",
"127.0.0.1",
],
{
env: {
...process.env,
NODE_ENV: "production",
PORT: String(PORT),
HOSTNAME: "127.0.0.1",
HEALTHLOG_PROCESS_TYPE: role,
SESSION_COOKIE_SECURE: "false",
NODE_OPTIONS: `--max-old-space-size=${HEAP_MB}`,
},
stdio: ["ignore", "ignore", "inherit"],
},
);
return child;
}
async function stopServer(child: ChildProcess): Promise<void> {
if (child.exitCode !== null) return;
const exited = new Promise((resolve) => child.once("exit", resolve));
child.kill("SIGTERM");
await Promise.race([exited, sleep(20_000)]);
if (child.exitCode === null) child.kill("SIGKILL");
}
function freshBatch(run: string, chunk: number): unknown[] {
// 500 heart-rate readings a minute apart, on a day inside the seeded
// window, with ids no earlier run used.
const day = Date.UTC(2026, 0, 1) + chunk * 86_400_000;
return Array.from({ length: 500 }, (_, i) => {
const at = new Date(day + i * 60_000).toISOString();
return {
hkIdentifier: "HKQuantityTypeIdentifierHeartRate",
value: 55 + (i % 40),
unit: "count/min",
startDate: at,
endDate: at,
externalId: `bench-${run}-${chunk}-${i}`,
};
});
}
function markdown(meta: Record<string, unknown>, rows: ScenarioResult[]) {
const lines = [
`# Benchmark ${String(meta.label)}`,
"",
`Commit ${String(meta.commit)}, ${String(meta.date)}. ${String(meta.measurements)} measurement rows on the bench account, ${ITERATIONS} measured requests per row after ${WARMUP} warm-up requests, one at a time. Server: production build, web process, heap ceiling ${HEAP_MB} MB. Postgres ${String(meta.postgres)}, ${String(meta.cpus)} cores, load average ${String(meta.loadAverage)} at the end. Idle background: ${String(meta.idleStatementsPerSecond)} statements/s.`,
"",
"| Request | Mode | n | p50 ms | p95 ms | max ms | statements (median) | statements (max) |",
"| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |",
...rows.map(
(r) =>
`| ${r.name} | ${r.mode} | ${r.n} | ${r.p50Ms} | ${r.p95Ms} | ${r.maxMs} | ${r.statementsMedian} | ${r.statementsMax} |`,
),
"",
];
return lines.join("\n");
}
async function main(): Promise<void> {
if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required");
await pool.query("CREATE EXTENSION IF NOT EXISTS pg_stat_statements");
await statementCount(); // fails here when the library is not preloaded
const user = await prisma.user.findUnique({
where: { id: USER_ID },
select: { id: true },
});
if (!user) throw new Error(`no user ${USER_ID}; run the seed first`);
const measurements = await prisma.measurement.count({
where: { userId: USER_ID },
});
token = (
await issueApiToken({
userId: USER_ID,
name: "benchmark",
permissions: ["*"],
expiresInDays: 1,
})
).token;
// A web-only process only sends jobs; the queues are created by the
// worker role. Boot the full process once so every queue exists (as it
// does in production), then run the measurement against a web-only
// process, so the worker's polling is not in every delta.
if (!BASE_URL) {
const full = startServer("all");
await waitForHealth(180_000, full);
await stopServer(full);
// Drop whatever that boot scheduled: nothing in the web-only process
// runs jobs, and a fold left queued would change the fixture on the
// next full boot.
await pool.query("TRUNCATE pgboss.job");
}
const server = BASE_URL ? null : startServer("web");
try {
await waitForHealth(180_000, server);
const idleBefore = await settledCount();
await sleep(5_000);
const idleStatementsPerSecond =
Math.round(((await statementCount()) - idleBefore) / 0.5) / 10;
const yesterday = new Date(Date.now() - 86_400_000)
.toISOString()
.slice(0, 10);
const reads: Array<[string, string]> = [
["/api/auth/me", "/api/auth/me"],
["dashboard summary", "/api/dashboard/summary"],
["dashboard snapshot", "/api/dashboard/snapshot"],
["series pulse 30 d", "/api/measurements/series?kind=pulse&days=30"],
["series pulse 365 d", "/api/measurements/series?kind=pulse&days=365"],
["series pulse 3650 d", "/api/measurements/series?kind=pulse&days=3650"],
["day view", `/api/day/${yesterday}`],
["insights health status", "/api/insights/health-status"],
["insights correlations", "/api/insights/correlations"],
];
const results: ScenarioResult[] = [];
for (const [name, path] of reads) {
await resetRateLimits();
for (let i = 0; i < WARMUP; i++) await call("GET", path);
const cold: Sample[] = [];
for (let i = 0; i < ITERATIONS; i++) {
await bust(yesterday);
cold.push(await call("GET", path));
}
results.push(summarise(name, "cold", `GET ${path}`, cold));
await resetRateLimits();
const warm: Sample[] = [];
for (let i = 0; i < ITERATIONS; i++) warm.push(await call("GET", path));
results.push(summarise(name, "warm", `GET ${path}`, warm));
console.log(
`${name.padEnd(26)} cold p95 ${results.at(-2)!.p95Ms} ms / ${results.at(-2)!.statementsMedian} st, warm p95 ${results.at(-1)!.p95Ms} ms / ${results.at(-1)!.statementsMedian} st`,
);
}
// Rows an earlier run wrote would turn this run's inserts into
// reconciles of the same natural key, so they go first, and again at
// the end, leaving the seeded fixture as it was.
const clean = () =>
pool.query(
`DELETE FROM measurements WHERE user_id = $1 AND external_id LIKE 'bench-%'`,
[USER_ID],
);
await clean();
await resetRateLimits();
const run = Date.now().toString(36);
const writes: Sample[] = [];
for (let chunk = 0; chunk < 10; chunk++) {
writes.push(
await call("POST", "/api/measurements/batch", {
entries: freshBatch(run, chunk),
}),
);
}
const batch = summarise(
"batch ingest 5k (per 500)",
"write",
"POST /api/measurements/batch ×10",
writes,
);
results.push(batch);
await clean();
const totalMs = writes.reduce((a, s) => a + s.ms, 0);
const totalStatements = writes.reduce((a, s) => a + s.statements, 0);
console.log(
`batch ingest 5k total ${Math.round(totalMs)} ms / ${totalStatements} st`,
);
let commit = "unknown";
try {
commit = execSync("git rev-parse --short HEAD").toString().trim();
} catch {
// not a checkout
}
const meta = {
label: OUT.split("/").pop(),
commit,
date: new Date().toISOString(),
node: process.version,
postgres: (
await pool.query<{ v: string }>(
"SELECT current_setting('server_version') AS v",
)
).rows[0]?.v,
cpus: cpus().length,
loadAverage: loadavg().map((n) => Math.round(n * 10) / 10),
measurements,
iterations: ITERATIONS,
warmup: WARMUP,
heapMb: HEAP_MB,
idleStatementsPerSecond,
batch5k: { totalMs: Math.round(totalMs), totalStatements },
};
mkdirSync(dirname(OUT), { recursive: true });
writeFileSync(
`${OUT}.json`,
`${JSON.stringify({ meta, results }, null, 2)}\n`,
);
writeFileSync(`${OUT}.md`, markdown(meta, results));
console.log(`wrote ${OUT}.json and ${OUT}.md`);
} finally {
if (server) await stopServer(server);
}
if (failures.length > 0) {
console.error(`non-2xx responses:\n ${failures.join("\n ")}`);
process.exitCode = 2;
}
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
await pool.end();
});
+659
View File
@@ -0,0 +1,659 @@
/**
* Seed one synthetic account the size of a heavy real one, for the benchmark
* harness (`scripts/bench/run.ts`) and for reproducing the maintenance jobs:
* about 1.26 million measurement rows over ten years, plus workouts, mood
* entries and medications with their intake history.
*
* DATABASE_URL=postgresql://… pnpm dlx tsx scripts/bench/seed-large-account.ts \
* [--user bench-large] [--years 10] [--anchor 2026-10-22] [--reset]
* [--no-rollups]
*
* Never point this at a database that holds real data: it inserts rows under
* the given user id and, with `--reset`, deletes that user (and, through the
* cascades, everything it owns) first.
*
* Deterministic: every value and timestamp is a function of the row index and
* the anchor day (`--anchor`, UTC midnight; default today), and ids are
* derived from the user id, the series and the index. Two runs with the same
* arguments write the same rows.
*
* The shape follows what the integrations write, with overlapping sources so
* the canonical-source pick has work to do:
*
* - Apple Health dense spot rows (heart rate about every ten minutes, HRV,
* SpO2, respiratory rate, gait metrics, audio exposure) keyed by a hashed
* sample id;
* - Withings morning and evening blood pressure with its pulse, and daily
* weight, body fat and muscle mass; a weekly manual weight beside it;
* - Oura nightly resting heart rate and RMSSD for the last three years,
* beside Apple Health's resting heart rate;
* - Nightscout CGM glucose every five minutes for the last 90 days;
* - one `stats:` daily row per cumulative type and day (the export import)
* and per-sample cumulative rows with opaque ids for the last 90 days
* (the iOS sync);
* - sleep-stage segments every night;
* - about 3,000 workouts, a mood entry a day for three years, and three
* daily medications with their intake events for three years.
*
* Everything goes in through set-based `INSERT … SELECT … generate_series`,
* so a million rows take seconds rather than a million round trips. The
* rollup tier is then folded with `recomputeUserRollups`, the same path the
* boot backfill takes for an imported account, so reads see the state a real
* account is in. `ANALYZE` runs at the end: a planner reading pre-seed
* statistics picks plans no production table of this size would get.
*/
import { prisma } from "@/lib/db";
import {
ALL_GRANULARITIES,
recomputeUserRollups,
} from "@/lib/rollups/measurement-rollups";
function arg(name: string, fallback: string): string {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1]! : fallback;
}
const USER_ID = arg("user", "bench-large");
const YEARS = Number(arg("years", "10"));
const ANCHOR = arg("anchor", new Date().toISOString().slice(0, 10));
const RESET = process.argv.includes("--reset");
const ROLLUPS = !process.argv.includes("--no-rollups");
const ZONE = "Europe/Berlin";
if (!/^\d{4}-\d{2}-\d{2}$/.test(ANCHOR)) {
throw new Error(`--anchor must be YYYY-MM-DD, got ${ANCHOR}`);
}
/**
* A spot series: rows evenly spaced over the last `days` days before the
* anchor. `perDay` above one spaces rows through the day; `everyDays` above
* one writes one row every that many days at `offsetSec` past midnight UTC.
*/
interface SpotSeries {
type: string;
unit: string;
source: string;
base: number;
spread: number;
days: number;
perDay?: number;
everyDays?: number;
offsetSec?: number;
/** externalId prefix; `stats:` rows are generated separately. */
idPrefix: string;
}
async function insertSpot(s: SpotSeries): Promise<number> {
const step = s.everyDays
? s.everyDays * 86_400
: Math.floor(86_400 / (s.perDay ?? 1));
const count = Math.floor((s.days * 86_400) / step);
// Jitter stays below the step so every measured_at is distinct per type
// and source (the (user, type, measured_at, source, sleep_stage) unique
// index). Series that share a step and offset (BP systolic, diastolic and
// the Withings pulse) share their timestamps, as a cuff reading does.
const jitter = Math.max(1, Math.min(59, step - 1));
return prisma.$executeRawUnsafe(
`
INSERT INTO measurements
(id, user_id, type, value, unit, source, measured_at, external_id, created_at, updated_at)
SELECT
'seed-' || $1 || '-' || $2 || '-' || $10 || '-' || g,
$1,
$2::measurement_type,
round(($3 + $4 * sin(g / 37.0) + ($4 / 2) * ((g * 7919) % 100) / 100.0)::numeric, 2),
$5,
$10::measurement_source,
$11::date::timestamp - make_interval(secs => $6 * $7::bigint)
+ make_interval(secs => g * $6 + $12 + (g * 31) % $8),
$9 || md5($2 || $10 || g::text),
now(), now()
FROM generate_series(0, $7::bigint - 1) AS g
`,
USER_ID,
s.type,
s.base,
s.spread,
s.unit,
step,
count,
jitter,
s.idPrefix,
s.source,
ANCHOR,
s.offsetSec ?? 0,
);
}
function log(label: string, n: number): void {
console.log(`${label.padEnd(40)} ${String(n).padStart(9)}`);
}
async function seedMeasurements(days: number): Promise<number> {
const recent = Math.min(days, 90);
const threeYears = Math.min(days, 3 * 365);
const spot: SpotSeries[] = [
// Apple Health, the dense tier.
{
type: "PULSE",
unit: "bpm",
source: "APPLE_HEALTH",
perDay: 147,
base: 62,
spread: 25,
days,
idPrefix: "hk-",
},
{
type: "HEART_RATE_VARIABILITY",
unit: "ms",
source: "APPLE_HEALTH",
perDay: 12,
base: 45,
spread: 20,
days,
idPrefix: "hk-",
},
{
type: "OXYGEN_SATURATION",
unit: "%",
source: "APPLE_HEALTH",
perDay: 24,
base: 96,
spread: 2,
days,
idPrefix: "hk-",
},
{
type: "RESPIRATORY_RATE",
unit: "breaths/min",
source: "APPLE_HEALTH",
perDay: 24,
base: 14,
spread: 3,
days,
idPrefix: "hk-",
},
{
type: "WALKING_SPEED",
unit: "m/s",
source: "APPLE_HEALTH",
perDay: 12,
base: 1.3,
spread: 0.2,
days,
idPrefix: "hk-",
},
{
type: "WALKING_STEP_LENGTH",
unit: "m",
source: "APPLE_HEALTH",
perDay: 12,
base: 0.72,
spread: 0.06,
days,
idPrefix: "hk-",
},
{
type: "WALKING_ASYMMETRY",
unit: "%",
source: "APPLE_HEALTH",
perDay: 12,
base: 3,
spread: 2,
days,
idPrefix: "hk-",
},
{
type: "WALKING_DOUBLE_SUPPORT",
unit: "%",
source: "APPLE_HEALTH",
perDay: 12,
base: 28,
spread: 3,
days,
idPrefix: "hk-",
},
{
type: "AUDIO_EXPOSURE_ENV",
unit: "dBA",
source: "APPLE_HEALTH",
perDay: 24,
base: 62,
spread: 12,
days,
idPrefix: "hk-",
},
{
type: "RESTING_HEART_RATE",
unit: "bpm",
source: "APPLE_HEALTH",
perDay: 1,
offsetSec: 6 * 3600,
base: 56,
spread: 4,
days,
idPrefix: "hk-",
},
{
type: "TIME_IN_DAYLIGHT",
unit: "minutes",
source: "APPLE_HEALTH",
perDay: 1,
offsetSec: 18 * 3600,
base: 60,
spread: 40,
days,
idPrefix: "hk-",
},
// The iOS sync's per-sample cumulative rows: dense, recent, opaque ids.
{
type: "ACTIVITY_STEPS",
unit: "steps",
source: "APPLE_HEALTH",
perDay: 100,
base: 90,
spread: 60,
days: recent,
idPrefix: "uuid-",
},
{
type: "ACTIVE_ENERGY_BURNED",
unit: "kcal",
source: "APPLE_HEALTH",
perDay: 400,
base: 1.2,
spread: 0.9,
days: recent,
idPrefix: "uuid-",
},
{
type: "WALKING_RUNNING_DISTANCE",
unit: "m",
source: "APPLE_HEALTH",
perDay: 100,
base: 70,
spread: 50,
days: recent,
idPrefix: "uuid-",
},
// Withings: a cuff reading morning and evening, and the scale.
{
type: "BLOOD_PRESSURE_SYS",
unit: "mmHg",
source: "WITHINGS",
perDay: 2,
offsetSec: 6 * 3600,
base: 126,
spread: 10,
days,
idPrefix: "wi-",
},
{
type: "BLOOD_PRESSURE_DIA",
unit: "mmHg",
source: "WITHINGS",
perDay: 2,
offsetSec: 6 * 3600,
base: 81,
spread: 7,
days,
idPrefix: "wi-",
},
{
type: "PULSE",
unit: "bpm",
source: "WITHINGS",
perDay: 2,
offsetSec: 6 * 3600,
base: 66,
spread: 8,
days,
idPrefix: "wi-",
},
{
type: "WEIGHT",
unit: "kg",
source: "WITHINGS",
perDay: 1,
offsetSec: 5 * 3600,
base: 82,
spread: 1.5,
days,
idPrefix: "wi-",
},
{
type: "BODY_FAT",
unit: "%",
source: "WITHINGS",
perDay: 1,
offsetSec: 5 * 3600,
base: 22,
spread: 2,
days,
idPrefix: "wi-",
},
{
type: "MUSCLE_MASS",
unit: "kg",
source: "WITHINGS",
perDay: 1,
offsetSec: 5 * 3600,
base: 61,
spread: 1,
days,
idPrefix: "wi-",
},
// A weekly manual weight beside the scale.
{
type: "WEIGHT",
unit: "kg",
source: "MANUAL",
everyDays: 7,
offsetSec: 19 * 3600,
base: 82.4,
spread: 1.5,
days,
idPrefix: "man-",
},
// Oura, nightly, the last three years.
{
type: "RESTING_HEART_RATE",
unit: "bpm",
source: "OURA",
perDay: 1,
offsetSec: 4 * 3600,
base: 54,
spread: 4,
days: threeYears,
idPrefix: "oura-",
},
{
type: "HRV_RMSSD",
unit: "ms",
source: "OURA",
perDay: 1,
offsetSec: 4 * 3600,
base: 48,
spread: 15,
days: threeYears,
idPrefix: "oura-",
},
// Nightscout CGM, every five minutes, the last 90 days.
{
type: "BLOOD_GLUCOSE",
unit: "mg/dL",
source: "NIGHTSCOUT",
perDay: 288,
base: 105,
spread: 30,
days: recent,
idPrefix: "ns-",
},
];
let total = 0;
for (const s of spot) {
const n = await insertSpot(s);
total += n;
log(`${s.type} (${s.source})`, n);
}
// One export-estimate `stats:` row per cumulative type and local day, at
// local noon — what `flushCumulativeBuckets` in the export import writes —
// for every day older than the per-sample window.
for (const [type, hk, unit, base] of [
["ACTIVITY_STEPS", "HKQuantityTypeIdentifierStepCount", "steps", 8000],
[
"ACTIVE_ENERGY_BURNED",
"HKQuantityTypeIdentifierActiveEnergyBurned",
"kcal",
550,
],
[
"WALKING_RUNNING_DISTANCE",
"HKQuantityTypeIdentifierDistanceWalkingRunning",
"m",
6000,
],
["FLIGHTS_CLIMBED", "HKQuantityTypeIdentifierFlightsClimbed", "flights", 9],
] as const) {
const n = await prisma.$executeRawUnsafe(
`
INSERT INTO measurements
(id, user_id, type, value, unit, source, measured_at, external_id,
aggregation_provenance, created_at, updated_at)
SELECT
'seed-' || $1 || '-' || $2 || '-stats-' || d,
$1, $2::measurement_type, $5 + (d * 7919) % ($5 / 2), $4, 'APPLE_HEALTH',
(($7::date - d)::timestamp + interval '12 hours')
AT TIME ZONE $8 AT TIME ZONE 'UTC',
'stats:' || $3 || ':' || to_char($7::date - d, 'YYYY-MM-DD'),
'EXPORT_XML_SOURCE_MAX', now(), now()
FROM generate_series(91, $6::int) AS d
`,
USER_ID,
type,
hk,
unit,
base,
days,
ANCHOR,
ZONE,
);
total += n;
log(`${type} (stats:)`, n);
}
// Sleep: 30 segments a night from 23:00 local, 16 minutes apart, rotating
// through the stages. Distinct measured_at per segment.
const sleep = await prisma.$executeRawUnsafe(
`
INSERT INTO measurements
(id, user_id, type, value, unit, source, measured_at, external_id,
sleep_stage, created_at, updated_at)
SELECT
'seed-' || $1 || '-sleep-' || d || '-' || s,
$1, 'SLEEP_DURATION', 16, 'minutes', 'APPLE_HEALTH',
(($3::date - d)::timestamp - interval '1 hour' + s * interval '16 minutes')
AT TIME ZONE $4 AT TIME ZONE 'UTC',
'hk-' || md5('sleep' || d || '-' || s),
(ARRAY['CORE','DEEP','CORE','REM','AWAKE','CORE']::sleep_stage[])[1 + s % 6],
now(), now()
FROM generate_series(1, $2::int) AS d, generate_series(0, 29) AS s
`,
USER_ID,
days,
ANCHOR,
ZONE,
);
total += sleep;
log("SLEEP_DURATION (APPLE_HEALTH)", sleep);
return total;
}
async function seedWorkouts(days: number): Promise<number> {
// About one workout every 29 hours, rotating through five sports.
const count = Math.floor((days * 24) / 29);
return prisma.$executeRawUnsafe(
`
INSERT INTO workouts
(id, user_id, sport_type, started_at, ended_at, duration_sec,
total_energy_kcal, total_distance_m, avg_heart_rate, max_heart_rate,
source, external_id, created_at, updated_at)
SELECT
'seed-' || $1 || '-workout-' || g,
$1,
(ARRAY['walking','running','cycling','hiking','swimming'])[1 + g % 5],
$3::date::timestamp - make_interval(hours => (g * 29)::int) + interval '17 hours',
$3::date::timestamp - make_interval(hours => (g * 29)::int) + interval '17 hours'
+ make_interval(secs => (1800 + (g * 97) % 3600)::int),
(1800 + (g * 97) % 3600)::int,
round((250 + (g * 13) % 400)::numeric, 1),
round((3000 + (g * 211) % 9000)::numeric, 1),
(115 + (g * 7) % 40)::int,
(150 + (g * 5) % 35)::int,
'APPLE_HEALTH',
'hk-workout-' || md5($1 || g::text),
now(), now()
FROM generate_series(0, $2::int - 1) AS g
`,
USER_ID,
count,
ANCHOR,
);
}
async function seedMood(days: number): Promise<number> {
const span = Math.min(days, 3 * 365);
return prisma.$executeRawUnsafe(
`
INSERT INTO mood_entries
(id, user_id, date, mood, score, mood_a1, stress_a2, energy_a3,
source, mood_logged_at, tz, synced_at, created_at, updated_at)
SELECT
'seed-' || $1 || '-mood-' || d,
$1,
to_char($3::date - d, 'YYYY-MM-DD'),
(ARRAY['LAUSIG','SCHLECHT','OKAY','GUT','SUPER_GUT'])[1 + (d * 7) % 5],
1 + (d * 7) % 5,
2 * ((d * 7) % 5) + 1,
(d * 3) % 11,
(d * 5) % 11,
'MANUAL',
(($3::date - d)::timestamp + interval '20 hours') AT TIME ZONE $4 AT TIME ZONE 'UTC',
$4,
now(), now(), now()
FROM generate_series(1, $2::int) AS d
`,
USER_ID,
span,
ANCHOR,
ZONE,
);
}
async function seedMedications(days: number): Promise<number> {
const span = Math.min(days, 3 * 365);
const meds = [
{ key: "a", name: "Synthetic A", dose: "5 mg", window: ["08:00", "09:00"] },
{
key: "b",
name: "Synthetic B",
dose: "100 mg",
window: ["08:00", "09:00"],
},
{
key: "c",
name: "Synthetic C",
dose: "20 mg",
window: ["20:00", "21:00"],
},
];
let events = 0;
for (const m of meds) {
const medId = `seed-${USER_ID}-med-${m.key}`;
await prisma.$executeRawUnsafe(
`
INSERT INTO medications
(id, user_id, name, dose, starts_on, created_at, updated_at)
VALUES ($1, $2, $3, $4, ($5::date - $6::int)::timestamp, now(), now())
`,
medId,
USER_ID,
m.name,
m.dose,
ANCHOR,
span,
);
await prisma.$executeRawUnsafe(
`
INSERT INTO medication_schedules
(id, medication_id, window_start, window_end, schedule_type)
VALUES ($1, $2, $3, $4, 'SCHEDULED')
`,
`${medId}-schedule`,
medId,
m.window[0],
m.window[1],
);
// One intake event per day; about one in twelve skipped, the rest taken
// a few minutes into the window.
events += await prisma.$executeRawUnsafe(
`
INSERT INTO medication_intake_events
(id, user_id, medication_id, scheduled_for, taken_at, skipped,
attribution_source, source, created_at, updated_at)
SELECT
$1 || '-intake-' || d,
$2,
$1,
(($3::date - d)::timestamp + $5::time) AT TIME ZONE $6 AT TIME ZONE 'UTC',
CASE WHEN d % 12 = 0 THEN NULL
ELSE (($3::date - d)::timestamp + $5::time
+ make_interval(mins => (d * 7) % 40)) AT TIME ZONE $6 AT TIME ZONE 'UTC'
END,
d % 12 = 0,
'AUTO',
'WEB',
now(), now()
FROM generate_series(1, $4::int) AS d
`,
medId,
USER_ID,
ANCHOR,
span,
m.window[0],
ZONE,
);
}
return events;
}
async function main(): Promise<void> {
const days = Math.round(YEARS * 365);
const started = Date.now();
if (RESET) {
await prisma.$executeRawUnsafe(`DELETE FROM users WHERE id = $1`, USER_ID);
}
await prisma.$executeRawUnsafe(
`INSERT INTO users (id, username, timezone, onboarding_completed_at, updated_at)
VALUES ($1, $1, $2, now(), now())
ON CONFLICT (id) DO NOTHING`,
USER_ID,
ZONE,
);
const measurements = await seedMeasurements(days);
log("measurements total", measurements);
log("workouts", await seedWorkouts(days));
log("mood entries", await seedMood(days));
log("medication intake events", await seedMedications(days));
if (ROLLUPS) {
const folded = await recomputeUserRollups(USER_ID, {
granularities: ALL_GRANULARITIES,
statementTimeoutMs: 600_000,
});
log("rollup rows", folded.rowsUpserted);
}
for (const table of [
"measurements",
"measurement_rollups",
"workouts",
"mood_entries",
"medication_intake_events",
]) {
await prisma.$executeRawUnsafe(`ANALYZE ${table}`);
}
console.log(`done in ${((Date.now() - started) / 1000).toFixed(1)} s`);
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
+263 -128
View File
@@ -2,55 +2,90 @@
/**
* Client-bundle report + budget gate for the Turbopack build.
*
* The webpack-era tooling died on Next 16: `ANALYZE=1` is a silent no-op
* under Turbopack and the old `.next/analyze/client.json` reader had
* nothing to read — so bundle regressions (a statically imported message
* catalog, a duplicated recharts chunk group) shipped invisibly. This
* script derives the signal straight from the build output instead:
* What a reader downloads is a set of chunk files: the shared
* `rootMainFiles` from `build-manifest.json` plus the chunks a page's
* `*_client-reference-manifest.js` names. This script measures that set per
* page and gates it, and separately measures how much distinct client code
* the build holds.
*
* - per-route client-JS totals (gzip) from the Turbopack
* `*_client-reference-manifest.js` files plus the shared
* `rootMainFiles` baseline from `build-manifest.json`;
* - the total gzip weight of every emitted client chunk;
* - a recharts-duplication guard: the number of chunks carrying the
* recharts library fingerprint must stay exactly 1 (the shared
* chart-runtime chunk group);
* - a catalog guard: no message-catalog-fingerprinted chunk may be
* referenced by any route's client-reference manifest or by the
* shared baseline (catalogs load only lazily / via /i18n/<locale>).
* METHOD
*
* First load per page. The union of `rootMainFiles` and the page's
* client-reference chunks, deduplicated by content hash (sha-256 of the
* file), each file gzipped at level 9 and summed. A file that two
* manifests list under different names counts once. Every page in the
* build is measured, not a hand-picked few: a heavy import into a page
* nobody watched is how Zod sat in fifty metric pages for three months.
*
* Across pages. p50, p95 and max of the per-page first loads.
*
* Unique client code. Every emitted chunk is evaluated in a `node:vm`
* sandbox with a stub `TURBOPACK` array, which collects the module
* factories the chunk registers without running any of them. Each factory
* is keyed by its Turbopack module id, which is global to the build, so a
* module the bundler copied into forty chunk groups counts once. (Not by
* the factory's text: each chunk is minified on its own, so the copies of
* one module differ in their mangled local names.) One copy per module,
* from the first chunk in file-name order, without the lazy message
* catalogs, is concatenated in id order and gzipped once. A file that
* registers no factories (the runtime, the polyfills) counts as one unit
* by its content hash.
*
* The old aggregate (every emitted chunk gzipped and summed) is still
* printed, for continuity, but no longer gated: it counts a module once
* per chunk group it lands in, so it rose every time a page got lighter
* by moving code into on-demand chunks.
*
* GATES (bundle-budget.json)
*
* - routeBaselineKbGz: measured first load per page; a page fails when it
* is more than routeDriftAllowanceKbGz over its baseline, or when it is
* a page with no baseline (write one with --write-baselines).
* - firstLoadP95KbGz / firstLoadMaxKbGz: caps on the distribution.
* - uniqueClientKbGz: cap on the distinct client code.
* - maxCatalogKbGz: per lazy message catalog.
* - maxRechartsChunks: chunks carrying the Recharts fingerprint (one, the
* shared chart-runtime group).
* - no message catalog referenced by a page or the shared baseline.
*
* Usage:
* pnpm bundle-report # print the report table
* pnpm bundle-report --check # enforce bundle-budget.json (CI gate)
* pnpm bundle-report # print the report
* pnpm bundle-check # enforce bundle-budget.json
* node scripts/check-bundle-budget.mjs --write-baselines
* # rewrite routeBaselineKbGz from
* # this build (a reviewed change)
* node scripts/check-bundle-budget.mjs --dir <build dir> [--json <file>]
*
* Budgets live in `bundle-budget.json` (repo root). Numbers are KB gzip.
*
* Per-route the file states a MEASURED baseline and one drift allowance
* over it, and a route fails on the delta rather than on a ceiling it is
* already touching. Flat ceilings had been re-stated but never re-based, so
* three routes ended up sitting within a kilobyte of theirs and any
* unrelated dependency bump failed the e2e job on a regression nobody had
* made. The delta is printed on every run, so a route that has crept most of
* the way through its allowance is readable in the log before it is red.
*
* The gate still exists to catch step changes (a ~100 KB statically imported
* catalog, a ~90 KB duplicate library), which are several times the
* allowance. Re-base consciously, in the same PR that pays the cost, with
* the reason written into `$comment`.
* Numbers are KB gzip. A local arm64 build measures each page about 1.5 KB
* lighter than the CI runner; the drift allowance covers that and ordinary
* week-to-week movement, while the step changes the gate exists for (a
* statically imported catalog, a duplicated chart library, Zod on a page
* that had none: 60 to 100 KB each) are several times larger.
*/
import { readFileSync, readdirSync, existsSync } from "node:fs";
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { join } from "node:path";
import vm from "node:vm";
import { gzipSync } from "node:zlib";
const CHECK = process.argv.includes("--check");
const BUILD_DIR = join(process.cwd(), ".next");
const argv = process.argv.slice(2);
const flag = (name) => argv.includes(name);
const option = (name) => {
const i = argv.indexOf(name);
return i >= 0 ? argv[i + 1] : undefined;
};
const CHECK = flag("--check");
const WRITE_BASELINES = flag("--write-baselines");
const BUILD_DIR = option("--dir") ?? join(process.cwd(), ".next");
const JSON_OUT = option("--json");
const APP_DIR = join(BUILD_DIR, "server", "app");
const BUDGET_PATH = join(process.cwd(), "bundle-budget.json");
if (!existsSync(join(BUILD_DIR, "build-manifest.json"))) {
console.error(
"check-bundle-budget: no .next/build-manifest.json — run `pnpm build` first.",
`check-bundle-budget: no ${join(BUILD_DIR, "build-manifest.json")} — run \`pnpm build\` first.`,
);
process.exit(2);
}
@@ -58,30 +93,47 @@ if (!existsSync(join(BUILD_DIR, "build-manifest.json"))) {
/** Recharts library fingerprint (present once in the shared runtime chunk). */
const RECHARTS_MARK = "CartesianGrid";
/**
* Message-catalog fingerprint. The key set is identical across all six
* locales, so a KEY-position match ("typeTimeInDaylight" directly followed
* by a colon, in either the plain or the JSON.parse-escaped chunk encoding)
* marks a bundled catalog. Source modules only ever carry the namespaced
* call-site form (`measurements.typeTimeInDaylight`), which matches neither.
* Message-catalog fingerprint. The key set is identical across all locales,
* so a KEY-position match ("typeTimeInDaylight" directly followed by a colon,
* in either the plain or the JSON.parse-escaped chunk encoding) marks a
* bundled catalog. Source modules only ever carry the namespaced call-site
* form (`measurements.typeTimeInDaylight`), which matches neither.
*/
const CATALOG_MARKS = ['"typeTimeInDaylight":', 'typeTimeInDaylight\\":'];
const sizeCache = new Map();
const sha = (buf) => createHash("sha256").update(buf).digest("hex");
const gz = (buf) => gzipSync(buf, { level: 9 }).length;
const kb = (bytes) => bytes / 1024;
const fmt = (bytes) => `${kb(bytes).toFixed(0)} KB`;
const round1 = (n) => Math.round(n * 10) / 10;
const fileCache = new Map();
function chunkInfo(file) {
if (!sizeCache.has(file)) {
if (!fileCache.has(file)) {
let info = {
hash: `missing:${file}`,
raw: 0,
gz: 0,
recharts: false,
catalog: false,
};
try {
const buf = readFileSync(join(BUILD_DIR, file));
sizeCache.set(file, {
info = {
hash: sha(buf),
raw: buf.length,
gz: gzipSync(buf, { level: 9 }).length,
gz: gz(buf),
recharts: buf.includes(RECHARTS_MARK),
catalog: CATALOG_MARKS.some((m) => buf.includes(m)),
});
buf,
};
} catch {
sizeCache.set(file, { raw: 0, gz: 0, recharts: false, catalog: false });
// A manifest naming a file the build did not emit: counted as zero and
// reported by its missing hash rather than crashing the report.
}
fileCache.set(file, info);
}
return sizeCache.get(file);
return fileCache.get(file);
}
function routeChunks(manifestFile) {
@@ -97,24 +149,54 @@ function routeChunks(manifestFile) {
return files;
}
function findRouteManifests(dir, prefix = "") {
function findPageManifests(dir, prefix = "") {
let out = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, entry.name);
if (entry.isDirectory()) {
out = out.concat(findRouteManifests(p, `${prefix}/${entry.name}`));
} else if (entry.name.endsWith("_client-reference-manifest.js")) {
out.push({
route: `${prefix}/${entry.name.replace("_client-reference-manifest.js", "")}`,
file: p,
});
out = out.concat(findPageManifests(p, `${prefix}/${entry.name}`));
} else if (entry.name === "page_client-reference-manifest.js") {
out.push({ route: `${prefix}/page`, file: p });
}
}
return out;
}
const kb = (bytes) => bytes / 1024;
const fmt = (bytes) => `${kb(bytes).toFixed(0)} KB`;
function percentile(sorted, p) {
if (sorted.length === 0) return 0;
const i = Math.min(
sorted.length - 1,
Math.ceil((p / 100) * sorted.length) - 1,
);
return sorted[Math.max(0, i)];
}
/**
* The `[module id, factory source]` pairs a Turbopack chunk registers,
* without running them: `TURBOPACK.push([script, id, factory, id, factory,
* …])`. Null when the file is not in that shape.
*/
function chunkFactories(buf) {
const sandbox = { TURBOPACK: [] };
sandbox.globalThis = sandbox;
sandbox.self = sandbox;
try {
vm.runInNewContext(buf.toString("utf8"), sandbox, { timeout: 5000 });
} catch {
return null;
}
const modules = [];
for (const entry of sandbox.TURBOPACK) {
if (!Array.isArray(entry)) continue;
for (let i = 1; i < entry.length; i++) {
if (typeof entry[i] !== "function") continue;
const id = entry[i - 1];
if (typeof id !== "number" && typeof id !== "string") continue;
modules.push([String(id), entry[i].toString()]);
}
}
return modules.length > 0 ? modules : null;
}
// ── Shared baseline ─────────────────────────────────────────────────────────
const buildManifest = JSON.parse(
@@ -123,88 +205,135 @@ const buildManifest = JSON.parse(
const rootFiles = buildManifest.rootMainFiles ?? [];
let rootGz = 0;
for (const f of rootFiles) rootGz += chunkInfo(f).gz;
const rootCatalogRef = rootFiles.some((f) => chunkInfo(f).catalog);
// ── Per-route totals ────────────────────────────────────────────────────────
const manifests = findRouteManifests(APP_DIR);
const routeTotals = new Map();
for (const { route, file } of manifests) {
let gz = 0;
// ── Per-page first load, deduplicated by content hash ──────────────────────
const pages = new Map();
for (const { route, file } of findPageManifests(APP_DIR)) {
const byHash = new Map();
let catalogRef = false;
for (const chunk of routeChunks(file)) {
for (const chunk of [...rootFiles, ...routeChunks(file)]) {
const info = chunkInfo(chunk);
gz += info.gz;
if (info.catalog) catalogRef = true;
byHash.set(info.hash, info.gz);
if (info.catalog && !rootFiles.includes(chunk)) catalogRef = true;
}
routeTotals.set(route, { gz: gz + rootGz, catalogRef });
let total = 0;
for (const size of byHash.values()) total += size;
pages.set(route, { gz: total, catalogRef });
}
const firstLoads = [...pages.values()].map((p) => p.gz).sort((a, b) => a - b);
const p50 = percentile(firstLoads, 50);
const p95 = percentile(firstLoads, 95);
const max = firstLoads[firstLoads.length - 1] ?? 0;
// ── Whole-build chunk scan ──────────────────────────────────────────────────
// ── Whole build: old aggregate, catalogs, recharts, unique modules ─────────
const chunkDir = join(BUILD_DIR, "static", "chunks");
let totalGz = 0;
let summedGz = 0;
let rechartsChunks = 0;
let catalogChunks = 0;
let catalogGz = 0;
let largestCatalog = { file: "-", gz: 0 };
let largest = { file: "-", gz: 0 };
for (const f of readdirSync(chunkDir)) {
const uniqueUnits = new Map();
for (const f of readdirSync(chunkDir).sort()) {
if (!f.endsWith(".js")) continue;
const info = chunkInfo(join("static", "chunks", f));
totalGz += info.gz;
summedGz += info.gz;
if (info.recharts) rechartsChunks += 1;
if (info.catalog) {
catalogChunks += 1;
catalogGz += info.gz;
if (info.gz > largestCatalog.gz) largestCatalog = { file: f, gz: info.gz };
continue;
}
const modules = info.buf ? chunkFactories(info.buf) : null;
if (modules) {
for (const [id, source] of modules) {
if (!uniqueUnits.has(`m:${id}`)) uniqueUnits.set(`m:${id}`, source);
}
} else if (info.buf) {
uniqueUnits.set(`f:${info.hash}`, info.buf.toString("utf8"));
}
if (info.gz > largest.gz) largest = { file: f, gz: info.gz };
}
const rootCatalogRef = rootFiles.some((f) => chunkInfo(f).catalog);
const uniqueKeys = [...uniqueUnits.keys()].sort();
const uniqueGz = gz(
Buffer.from(uniqueKeys.map((k) => uniqueUnits.get(k)).join("\n"), "utf8"),
);
// ── Report ──────────────────────────────────────────────────────────────────
const budget = existsSync(BUDGET_PATH)
? JSON.parse(readFileSync(BUDGET_PATH, "utf8"))
: null;
const baselines = budget?.routeBaselineKbGz ?? {};
const allowanceKb = budget?.routeDriftAllowanceKbGz ?? 0;
console.log(`pages measured: ${pages.size}`);
console.log(`shared baseline (rootMainFiles): ${fmt(rootGz)} gz`);
console.log(`all client chunks: ${fmt(totalGz)} gz`);
console.log(
` lazy message catalogs: ${fmt(catalogGz)} gz (${catalogChunks} chunks)`,
);
console.log(` other client chunks: ${fmt(totalGz - catalogGz)} gz`);
console.log(
` largest message catalog: ${fmt(largestCatalog.gz)} gz (${largestCatalog.file})`,
`first load across pages: p50 ${fmt(p50)}, p95 ${fmt(p95)}, max ${fmt(max)} gz`,
);
console.log(
`largest chunk: ${fmt(largest.gz)} gz ${largest.file}`,
`unique client code: ${fmt(uniqueGz)} gz (${uniqueKeys.length} distinct modules, catalogs excluded)`,
);
console.log(
`lazy message catalogs: ${fmt(catalogGz)} gz (${catalogChunks} chunks, largest ${fmt(largestCatalog.gz)} ${largestCatalog.file})`,
);
console.log(
`summed chunks, not gated: ${fmt(summedGz - catalogGz)} gz (every emitted chunk once, catalogs excluded)`,
);
console.log(`recharts-fingerprint chunks: ${rechartsChunks}`);
console.log("");
const baselines = budget?.routeBaselineKbGz ?? {};
const allowanceKb = budget?.routeDriftAllowanceKbGz ?? 0;
/** The ceiling a route's measured baseline plus the drift allowance buys it. */
const capFor = (route) =>
baselines[route] == null ? null : baselines[route] + allowanceKb;
const watched = Object.keys(baselines);
const rows = [...routeTotals.entries()]
.filter(([route]) => !watched.length || watched.includes(route))
.sort((a, b) => b[1].gz - a[1].gz);
for (const [route, { gz }] of rows.slice(0, watched.length || 15)) {
const baseline = baselines[route];
if (baseline == null) {
console.log(`${fmt(gz).padStart(8)} gz ${route}`);
continue;
}
const delta = kb(gz) - baseline;
const sign = delta >= 0 ? "+" : "−";
const rows = [...pages.entries()]
.map(([route, { gz: size }]) => {
const baseline = baselines[route];
return {
route,
kb: round1(kb(size)),
baseline: baseline ?? null,
delta: baseline == null ? null : round1(kb(size) - baseline),
};
})
.sort((a, b) => (b.delta ?? Infinity) - (a.delta ?? Infinity) || b.kb - a.kb);
console.log("largest movers against their baseline:");
for (const r of rows.slice(0, 15)) {
const delta =
r.delta == null
? "no baseline"
: `${r.delta >= 0 ? "+" : "−"}${Math.abs(r.delta).toFixed(1)} KB of ${allowanceKb}`;
console.log(
`${fmt(gz).padStart(8)} gz ${route} (baseline ${baseline} KB, ` +
`${sign}${Math.abs(delta).toFixed(1)} KB of ${allowanceKb} KB allowed, ` +
`cap ${capFor(route)} KB)`,
`${`${r.kb.toFixed(0)} KB`.padStart(8)} gz ${r.route} (${delta})`,
);
}
const report = {
pages: Object.fromEntries(rows.map((r) => [r.route, r.kb])),
firstLoadKbGz: {
p50: round1(kb(p50)),
p95: round1(kb(p95)),
max: round1(kb(max)),
},
uniqueClientKbGz: round1(kb(uniqueGz)),
summedChunksKbGz: round1(kb(summedGz - catalogGz)),
catalogKbGz: round1(kb(catalogGz)),
largestCatalogKbGz: round1(kb(largestCatalog.gz)),
rechartsChunks,
};
if (JSON_OUT) writeFileSync(JSON_OUT, `${JSON.stringify(report, null, 2)}\n`);
if (WRITE_BASELINES) {
if (!budget) {
console.error("check-bundle-budget: bundle-budget.json missing.");
process.exit(2);
}
budget.routeBaselineKbGz = Object.fromEntries(
rows
.map((r) => [r.route, Math.round(r.kb)])
.sort(([a], [b]) => a.localeCompare(b)),
);
writeFileSync(BUDGET_PATH, `${JSON.stringify(budget, null, 2)}\n`);
console.log(`\nwrote ${rows.length} page baselines to bundle-budget.json`);
}
if (!CHECK) process.exit(0);
// ── Budget gate ─────────────────────────────────────────────────────────────
@@ -212,42 +341,48 @@ if (!budget) {
console.error("check-bundle-budget: bundle-budget.json missing.");
process.exit(2);
}
// A budget file that states no baselines gates nothing, and would do it
// quietly — the loop below would simply have nothing to walk. Say so instead.
if (watched.length === 0) {
// A budget that states no page baselines gates nothing, and would do it
// quietly. A build with no pages is a broken build, not a light one.
if (Object.keys(baselines).length === 0 || pages.size === 0) {
console.error(
"check-bundle-budget: bundle-budget.json states no routeBaselineKbGz " +
"entries, so no route is gated. Measure the eager routes and write " +
"them in, with a routeDriftAllowanceKbGz over them.",
"check-bundle-budget: no page baselines in bundle-budget.json, or no " +
"pages in the build. Measure them with --write-baselines.",
);
process.exit(2);
}
const failures = [];
for (const [route, baselineKb] of Object.entries(baselines)) {
const actual = routeTotals.get(route);
if (!actual) {
failures.push(`route ${route} not found in the build output`);
continue;
}
const delta = kb(actual.gz) - baselineKb;
if (delta > allowanceKb) {
for (const r of rows) {
if (r.baseline == null) {
failures.push(
`${route}: ${fmt(actual.gz)} gz is ${delta.toFixed(1)} KB over the ` +
`${baselineKb} KB baseline, past the ${allowanceKb} KB drift ` +
`allowance (cap ${capFor(route)} KB)`,
`${r.route}: ${r.kb} KB gz and no baseline. A new page gets one with --write-baselines in the same change.`,
);
} else if (r.delta > allowanceKb) {
failures.push(
`${r.route}: ${r.kb} KB gz is ${r.delta.toFixed(1)} KB over its ${r.baseline} KB baseline, past the ${allowanceKb} KB drift allowance`,
);
}
}
// The aggregate leaves the lazy message catalogs out. A reader downloads one
// catalog, the one for their language, so counting all of them made every new
// interface language look like a regression. Each catalog is held to its own
// ceiling instead.
const nonCatalogGz = totalGz - catalogGz;
if (budget.totalClientKbGz && kb(nonCatalogGz) > budget.totalClientKbGz) {
for (const route of Object.keys(baselines)) {
if (!pages.has(route)) {
failures.push(
`${route} has a baseline but is not in the build; remove it with --write-baselines`,
);
}
}
if (budget.firstLoadP95KbGz && kb(p95) > budget.firstLoadP95KbGz) {
failures.push(
`client JS without the lazy catalogs ${fmt(nonCatalogGz)} gz exceeds the ${budget.totalClientKbGz} KB budget`,
`first-load p95 ${kb(p95).toFixed(1)} KB gz exceeds the ${budget.firstLoadP95KbGz} KB budget`,
);
}
if (budget.firstLoadMaxKbGz && kb(max) > budget.firstLoadMaxKbGz) {
failures.push(
`largest first load ${kb(max).toFixed(1)} KB gz exceeds the ${budget.firstLoadMaxKbGz} KB budget`,
);
}
if (budget.uniqueClientKbGz && kb(uniqueGz) > budget.uniqueClientKbGz) {
failures.push(
`unique client code ${kb(uniqueGz).toFixed(1)} KB gz exceeds the ${budget.uniqueClientKbGz} KB budget`,
);
}
if (budget.maxCatalogKbGz && kb(largestCatalog.gz) > budget.maxCatalogKbGz) {
@@ -263,12 +398,12 @@ if (
`${rechartsChunks} recharts-fingerprint chunks (budget ${budget.maxRechartsChunks}) — a chart import bypassed the shared chart-runtime boundary`,
);
}
const catalogRefRoutes = [...routeTotals.entries()]
const catalogRefPages = [...pages.entries()]
.filter(([, v]) => v.catalogRef)
.map(([r]) => r);
if (catalogRefRoutes.length > 0 || rootCatalogRef) {
if (catalogRefPages.length > 0 || rootCatalogRef) {
failures.push(
`message catalog statically referenced by ${rootCatalogRef ? "the shared baseline" : catalogRefRoutes.join(", ")} — catalogs must stay lazy (/i18n/<locale> + dynamic import)`,
`message catalog statically referenced by ${rootCatalogRef ? "the shared baseline" : catalogRefPages.join(", ")} — catalogs must stay lazy (/i18n/<locale> + dynamic import)`,
);
}
+1 -1
View File
@@ -28,7 +28,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(__dirname, "..");
const outDir = join(repoRoot, "public", "i18n");
const LOCALES = ["de", "en", "fr", "es", "it", "pl", "ko", "nl"];
const LOCALES = ["de", "en", "fr", "es", "it", "pl", "ko", "nl", "pt-BR"];
mkdirSync(outDir, { recursive: true });
+1 -1
View File
@@ -11,7 +11,7 @@
*/
import { readFileSync, writeFileSync } from "node:fs";
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"];
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl", "pt-BR"];
const file = (loc) => `messages/${loc}.json`;
function read(loc) {
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env node
/**
* Check each module's mutation score against its floor.
*
* node scripts/mutation-floors.mjs [reports/mutation/mutation.json]
*
* Stryker's own `thresholds.break` is one number for the whole run, which
* lets a well-tested module hide a weak one. `stryker.floors.json` names a
* floor per module instead (a module is a set of path prefixes), and this
* script reads the JSON report Stryker writes and fails when any module
* scores below its floor. The score is Stryker's: killed and timed-out
* mutants over every mutant that was valid and covered or not, so a mutant no
* test reaches counts against the module.
*
* Floors sit just below the last measured score. Raise one when a change
* raises the score; a floor that is lowered to let a run pass has stopped
* being a floor.
*/
import { existsSync, readFileSync } from "node:fs";
import { relative, resolve } from "node:path";
const root = resolve(import.meta.dirname, "..");
const reportPath = resolve(
root,
process.argv[2] ?? "reports/mutation/mutation.json",
);
const floorsPath = resolve(root, "stryker.floors.json");
if (!existsSync(reportPath)) {
console.error(
`no mutation report at ${reportPath}; run pnpm exec stryker run`,
);
process.exit(2);
}
const report = JSON.parse(readFileSync(reportPath, "utf8"));
const { modules } = JSON.parse(readFileSync(floorsPath, "utf8"));
const DETECTED = new Set(["Killed", "Timeout"]);
const UNDETECTED = new Set(["Survived", "NoCoverage"]);
const files = Object.entries(report.files ?? {}).map(([path, file]) => [
relative(root, resolve(root, path)).split("\\").join("/"),
file,
]);
let failed = false;
const rows = [];
for (const [name, { paths, floor }] of Object.entries(modules)) {
let detected = 0;
let undetected = 0;
let matched = 0;
for (const [path, file] of files) {
if (!paths.some((prefix) => path.startsWith(prefix))) continue;
matched += 1;
for (const mutant of file.mutants ?? []) {
if (DETECTED.has(mutant.status)) detected += 1;
else if (UNDETECTED.has(mutant.status)) undetected += 1;
}
}
const total = detected + undetected;
const score = total === 0 ? null : (100 * detected) / total;
const ok = matched > 0 && score !== null && score >= floor;
if (!ok) failed = true;
rows.push(
`${ok ? "ok " : "FAIL"} ${name.padEnd(16)} ${
score === null ? "no mutants" : `${score.toFixed(1)} %`
} (floor ${floor} %, ${detected}/${total} mutants, ${matched} files)`,
);
}
console.log(rows.join("\n"));
if (failed) {
console.error(
"\nA module scored below its floor, or the report covered none of its files.",
);
process.exit(1);
}
-296
View File
@@ -1,296 +0,0 @@
/**
* Seed one account shaped like a multi-year Apple Health `export.xml` import,
* for reproducing the maintenance jobs at the size a real heavy account
* reaches (issue #1031: about 1.26 million measurement rows).
*
* DATABASE_URL=postgresql://… pnpm dlx tsx scripts/seed-large-account.ts \
* [--user bench-large] [--years 4] [--pulse-seconds 165]
*
* Never point this at a database that holds real data: it inserts rows under
* the given user id and, with `--reset`, deletes that user first.
*
* The shape follows what the import and the iOS sync actually write:
*
* - dense spot rows (heart rate every `--pulse-seconds`, HRV, SpO2) keyed by
* a hashed sample id, the tier the dense-intraday retention folds;
* - high-frequency mean rows (respiratory rate, gait metrics, audio
* exposure), the tier the daily-mean consolidation folds;
* - one `stats:` daily row per cumulative type and day (what the export
* import writes for steps, energy, distance and flights);
* - per-sample cumulative rows with opaque sample ids for the most recent
* 90 days (what the iOS sync writes, and what the cumulative drain folds);
* - sleep-stage segments every night.
*
* Everything is generated inside Postgres with `generate_series`, so a million
* rows take seconds rather than a million round trips. `ANALYZE` runs at the
* end: a planner reading pre-seed statistics picks plans no production table
* of this size would get.
*/
import { prisma } from "@/lib/db";
function arg(name: string, fallback: string): string {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1]! : fallback;
}
const USER_ID = arg("user", "bench-large");
const YEARS = Number(arg("years", "4"));
const PULSE_SECONDS = Number(arg("pulse-seconds", "165"));
const RESET = process.argv.includes("--reset");
/** A spot series: `perDay` evenly spaced rows per day over the whole span. */
interface SpotSeries {
type: string;
unit: string;
perDay: number;
base: number;
spread: number;
/** Days back from now the series covers. Defaults to the whole span. */
days?: number;
/** externalId prefix; `stats:` rows are generated separately. */
idPrefix: string;
}
async function insertSpot(s: SpotSeries, days: number): Promise<number> {
const step = Math.floor(86_400 / s.perDay);
const count = Math.floor((days * 86_400) / step);
// Jitter stays below the step so every measured_at is distinct per type
// (the (user, type, measured_at, source, sleep_stage) unique index).
const jitter = Math.max(1, Math.min(59, step - 1));
return prisma.$executeRawUnsafe(
`
INSERT INTO measurements
(id, user_id, type, value, unit, source, measured_at, external_id, created_at, updated_at)
SELECT
'seed-' || $1 || '-' || $2 || '-' || g,
$1,
$2::measurement_type,
round(($3 + $4 * sin(g / 37.0) + ($4 / 2) * ((g * 7919) % 100) / 100.0)::numeric, 2),
$5,
'APPLE_HEALTH',
date_trunc('minute', now()) - make_interval(secs => $6 * $7::bigint)
+ make_interval(secs => g * $6 + (g * 31) % $8),
$9 || md5($2 || g::text),
now(), now()
FROM generate_series(0, $7::bigint - 1) AS g
`,
USER_ID,
s.type,
s.base,
s.spread,
s.unit,
step,
count,
jitter,
s.idPrefix,
);
}
async function main(): Promise<void> {
const days = Math.round(YEARS * 365);
if (RESET) {
await prisma.$executeRawUnsafe(`DELETE FROM users WHERE id = $1`, USER_ID);
}
await prisma.$executeRawUnsafe(
`INSERT INTO users (id, username, timezone, updated_at)
VALUES ($1, $1, 'Europe/Berlin', now())
ON CONFLICT (id) DO NOTHING`,
USER_ID,
);
const pulsePerDay = Math.floor(86_400 / PULSE_SECONDS);
const spot: SpotSeries[] = [
{
type: "PULSE",
unit: "bpm",
perDay: pulsePerDay,
base: 62,
spread: 25,
idPrefix: "hk-",
},
{
type: "HEART_RATE_VARIABILITY",
unit: "ms",
perDay: 12,
base: 45,
spread: 20,
idPrefix: "hk-",
},
{
type: "OXYGEN_SATURATION",
unit: "%",
perDay: 24,
base: 96,
spread: 2,
idPrefix: "hk-",
},
{
type: "RESPIRATORY_RATE",
unit: "breaths/min",
perDay: 48,
base: 14,
spread: 3,
idPrefix: "hk-",
},
{
type: "WALKING_SPEED",
unit: "km/h",
perDay: 30,
base: 4.8,
spread: 0.8,
idPrefix: "hk-",
},
{
type: "WALKING_STEP_LENGTH",
unit: "cm",
perDay: 30,
base: 72,
spread: 6,
idPrefix: "hk-",
},
{
type: "WALKING_ASYMMETRY",
unit: "%",
perDay: 30,
base: 3,
spread: 2,
idPrefix: "hk-",
},
{
type: "WALKING_DOUBLE_SUPPORT",
unit: "%",
perDay: 30,
base: 28,
spread: 3,
idPrefix: "hk-",
},
{
type: "AUDIO_EXPOSURE_ENV",
unit: "dB",
perDay: 60,
base: 62,
spread: 12,
idPrefix: "hk-",
},
// The iOS sync's per-sample cumulative rows: dense, recent, opaque ids.
{
type: "ACTIVITY_STEPS",
unit: "steps",
perDay: 100,
base: 90,
spread: 60,
days: 90,
idPrefix: "uuid-",
},
{
type: "ACTIVE_ENERGY_BURNED",
unit: "kcal",
perDay: 400,
base: 1.2,
spread: 0.9,
days: 90,
idPrefix: "uuid-",
},
{
type: "WALKING_RUNNING_DISTANCE",
unit: "m",
perDay: 100,
base: 70,
spread: 50,
days: 90,
idPrefix: "uuid-",
},
{
type: "WEIGHT",
unit: "kg",
perDay: 1,
base: 82,
spread: 1.5,
idPrefix: "hk-",
},
];
let total = 0;
for (const s of spot) {
const n = await insertSpot(s, s.days ?? days);
total += n;
console.log(`${s.type.padEnd(28)} ${String(n).padStart(9)}`);
}
// One export-estimate `stats:` row per cumulative type and local day, at
// local noon — what `flushCumulativeBuckets` in the export import writes.
for (const [type, hk, unit, base] of [
["ACTIVITY_STEPS", "HKQuantityTypeIdentifierStepCount", "steps", 8000],
[
"ACTIVE_ENERGY_BURNED",
"HKQuantityTypeIdentifierActiveEnergyBurned",
"kcal",
550,
],
[
"WALKING_RUNNING_DISTANCE",
"HKQuantityTypeIdentifierDistanceWalkingRunning",
"m",
6000,
],
["FLIGHTS_CLIMBED", "HKQuantityTypeIdentifierFlightsClimbed", "count", 9],
] as const) {
const n = await prisma.$executeRawUnsafe(
`
INSERT INTO measurements
(id, user_id, type, value, unit, source, measured_at, external_id,
aggregation_provenance, created_at, updated_at)
SELECT
'seed-' || $1 || '-' || $2 || '-stats-' || d,
$1, $2::measurement_type, $5 + (d * 7919) % ($5 / 2), $4, 'APPLE_HEALTH',
((current_date - d)::timestamp + interval '12 hours')
AT TIME ZONE 'Europe/Berlin' AT TIME ZONE 'UTC',
'stats:' || $3 || ':' || to_char(current_date - d, 'YYYY-MM-DD'),
'EXPORT_XML_SOURCE_MAX', now(), now()
FROM generate_series(91, $6::int) AS d
`,
USER_ID,
type,
hk,
unit,
base,
days,
);
total += n;
console.log(`${`${type} (stats:)`.padEnd(28)} ${String(n).padStart(9)}`);
}
// Sleep: 30 segments a night from 23:00 local, 16 minutes apart, rotating
// through the stages. Distinct measured_at per segment.
const sleep = await prisma.$executeRawUnsafe(
`
INSERT INTO measurements
(id, user_id, type, value, unit, source, measured_at, external_id,
sleep_stage, created_at, updated_at)
SELECT
'seed-' || $1 || '-sleep-' || d || '-' || s,
$1, 'SLEEP_DURATION', 16, 'minutes', 'APPLE_HEALTH',
((current_date - d)::timestamp - interval '1 hour' + s * interval '16 minutes')
AT TIME ZONE 'Europe/Berlin' AT TIME ZONE 'UTC',
'hk-' || md5('sleep' || d || '-' || s),
(ARRAY['CORE','DEEP','CORE','REM','AWAKE','CORE']::sleep_stage[])[1 + s % 6],
now(), now()
FROM generate_series(1, $2::int) AS d, generate_series(0, 29) AS s
`,
USER_ID,
days,
);
total += sleep;
console.log(`${"SLEEP_DURATION".padEnd(28)} ${String(sleep).padStart(9)}`);
await prisma.$executeRawUnsafe(`ANALYZE measurements`);
console.log(`${"total".padEnd(28)} ${String(total).padStart(9)}`);
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
+1 -1
View File
@@ -18,7 +18,7 @@
*/
import { readFileSync, writeFileSync } from "node:fs";
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"];
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl", "pt-BR"];
const BOUNDARY = /(?<=[.!?])\s+(?=[A-ZÄÖÜÀÂÉÈÊÎÔÙÛÇŁŚŹŻĄĆĘŃÓ0-9„"„«])/u;
/**
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env node
/**
* Regenerate a timing file for the duration-balanced sequencer
* (`tests/durations/sequencer.ts`) from Vitest JSON reports.
*
* node scripts/test-durations.mjs <suite> <report.json> [<report.json> …]
*
* `<suite>` is `unit` or `integration` and names the file written,
* `tests/durations/<suite>.json`. Pass every shard's report of one run; a
* file measured twice keeps the longer reading. Files the reports do not
* mention keep their previous value, so a partial run refines the table
* rather than emptying it, and files that no longer exist are dropped.
*
* Produce a report with
* pnpm exec vitest run [--config …] --reporter=default \
* --reporter=json --outputFile.json=report.json
* The nightly shuffled workflow uploads the reports of a CI run, which is
* the better source: CI is where the shards have to balance.
*/
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { relative, resolve } from "node:path";
const root = resolve(import.meta.dirname, "..");
const [suite, ...reports] = process.argv.slice(2);
if (!["unit", "integration"].includes(suite ?? "") || reports.length === 0) {
console.error(
"usage: node scripts/test-durations.mjs <unit|integration> <report.json>…",
);
process.exit(2);
}
const target = resolve(root, "tests/durations", `${suite}.json`);
const previous = existsSync(target)
? JSON.parse(readFileSync(target, "utf8")).files
: {};
const measured = new Map();
for (const report of reports) {
const parsed = JSON.parse(readFileSync(report, "utf8"));
for (const result of parsed.testResults ?? []) {
if (typeof result.startTime !== "number") continue;
if (typeof result.endTime !== "number") continue;
const file = relative(root, result.name).split("\\").join("/");
const seconds = Math.max(0, (result.endTime - result.startTime) / 1000);
measured.set(file, Math.max(measured.get(file) ?? 0, seconds));
}
}
if (measured.size === 0) {
console.error("the reports carry no per-file timings; nothing written");
process.exit(1);
}
const files = {};
const keys = new Set([...Object.keys(previous), ...measured.keys()]);
for (const file of [...keys].sort()) {
if (!existsSync(resolve(root, file))) continue;
const seconds = measured.get(file) ?? previous[file];
files[file] = Math.round(seconds * 10) / 10;
}
writeFileSync(
target,
JSON.stringify(
{
source: `vitest JSON report of ${measured.size} files, written ${new Date().toISOString().slice(0, 10)}`,
files,
},
null,
2,
) + "\n",
);
const total = Object.values(files).reduce((a, b) => a + b, 0);
console.log(
`${target}: ${Object.keys(files).length} files, ${Math.round(total)} s in total`,
);
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env node
/**
* Checks that every shipped locale has a message catalog that parses.
*
* The server reads `messages/<locale>.json` from disk at run time
* (`src/lib/i18n/server-catalogs.ts`); nothing is bundled, so an image that
* lost the directory, or one file of it, builds and starts clean and only
* shows the gap when a page asks for that locale. The Dockerfile runs this
* twice: in the builder against the locale list in `src/lib/i18n/config.ts`,
* writing that list out, and in the runner stage against the catalogs the
* image actually carries.
*
* node scripts/verify-message-catalogs.mjs --dir messages \
* --config src/lib/i18n/config.ts [--write .message-locales]
* node verify-message-catalogs.mjs --dir /app/messages \
* --locales .message-locales
*
* Exits 1 with every problem listed when a catalog is missing, does not parse
* or is not a JSON object, or when the locale list cannot be read.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
function arg(name) {
const index = process.argv.indexOf(`--${name}`);
return index === -1 ? null : (process.argv[index + 1] ?? null);
}
function localesFromConfig(path) {
const source = readFileSync(path, "utf8");
const block = /export const locales = \[([^\]]*)\]/.exec(source);
if (!block) throw new Error(`no \`export const locales = [...]\` in ${path}`);
const list = [...block[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]);
if (list.length === 0) throw new Error(`the locale list in ${path} is empty`);
return list;
}
function localesFromFile(path) {
const list = readFileSync(path, "utf8").split("\n").filter(Boolean);
if (list.length === 0) throw new Error(`the locale list in ${path} is empty`);
return list;
}
const dir = arg("dir");
const config = arg("config");
const listFile = arg("locales");
if (!dir || (!config && !listFile)) {
console.error(
"usage: verify-message-catalogs.mjs --dir <messages> (--config <config.ts> | --locales <file>) [--write <file>]",
);
process.exit(2);
}
let locales;
try {
locales = config ? localesFromConfig(config) : localesFromFile(listFile);
} catch (error) {
console.error(`ERROR: ${error.message}`);
process.exit(1);
}
const problems = [];
for (const locale of locales) {
const path = join(dir, `${locale}.json`);
let text;
try {
text = readFileSync(path, "utf8");
} catch (error) {
problems.push(
`${path}: ${error.code === "ENOENT" ? "missing" : error.message}`,
);
continue;
}
try {
const parsed = JSON.parse(text);
if (
parsed === null ||
typeof parsed !== "object" ||
Array.isArray(parsed)
) {
problems.push(`${path}: not a JSON object`);
}
} catch (error) {
problems.push(`${path}: does not parse (${error.message})`);
}
}
if (problems.length > 0) {
console.error(
"ERROR: message catalogs the server reads at run time are broken:",
);
for (const problem of problems) console.error(` ${problem}`);
process.exit(1);
}
const write = arg("write");
if (write) writeFileSync(write, `${locales.join("\n")}\n`);
console.log(`message catalogs ok: ${locales.join(", ")} in ${dir}`);
@@ -14,10 +14,13 @@
* those.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
import {
readSource,
readSourceText,
walkSourceFiles,
} from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
@@ -30,7 +33,7 @@ function sourceFiles(): string[] {
}
function read(rel: string): string {
return readFileSync(join(SRC, rel), "utf8");
return readSourceText(join(SRC, rel));
}
function filesMatching(re: RegExp): string[] {
@@ -177,7 +180,7 @@ describe("the cookie-only helpers cannot see the acting account", () => {
const COOKIE_ONLY = ["requireAdmin", "requireCookieAuth", "requireFreshMfa"];
it.each(COOKIE_ONLY)("%s resolves the actor and nothing else", (name) => {
const body = functionBody(read("lib/api-handler.ts"), name);
const body = functionBody(readSource("src/lib/api-handler.ts", name), name);
// Non-zero proof, twice over: the slice found a function, and the slice is
// the function it claims to be. A renamed helper must fail here rather
@@ -191,7 +194,7 @@ describe("the cookie-only helpers cannot see the acting account", () => {
});
it("substitutes a data-scope user in exactly two functions", () => {
const src = read("lib/api-handler.ts");
const src = readSource("src/lib/api-handler.ts", "requireRecordAuth");
const record = functionBody(src, "requireRecordAuth");
expect(record).toContain("readActingCarrier");
@@ -292,7 +295,7 @@ describe("ending a grant and clearing the switch are one act", () => {
it("runs the transition and the cleanup in one transaction", () => {
const body = privateFunctionBody(
read("lib/sharing/grants.ts"),
readSource("src/lib/sharing/grants.ts", "endAndClear"),
"endAndClear",
);
// Non-zero proof: a renamed helper fails here rather than asserting
@@ -309,7 +312,7 @@ describe("ending a grant and clearing the switch are one act", () => {
// claim's own transaction (it takes `tx`), so the guard here is that the
// cleanup is part of the same function as the ending.
const body = functionBody(
read("lib/sharing/grants.ts"),
readSource("src/lib/sharing/grants.ts", "settleHandoverGuardianGrant"),
"settleHandoverGuardianGrant",
);
expect(body.length).toBeGreaterThan(0);
@@ -26,14 +26,17 @@
* another module would slip it; `AI_ROUTE_PATTERNS` lists the literal shapes
* the tree uses today.
*/
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { AiCapabilityKey } from "@/lib/ai/capabilities/types";
import { stripComments, walkSourceFiles } from "./helpers/source-files";
import {
readSourceText,
stripComments,
walkSourceFiles,
} from "./helpers/source-files";
const SRC = path.resolve(__dirname, "..");
@@ -64,7 +67,7 @@ const FEATURE_FLAG_SWEEP = NON_TEST_FILES.filter(
);
const read = (rel: string) =>
stripComments(readFileSync(path.join(SRC, rel), "utf8"));
stripComments(readSourceText(path.join(SRC, rel)));
/** Request literals of routes that call a model or serve model text. */
const AI_ROUTE_PATTERNS: readonly RegExp[] = [
@@ -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",
+54 -11
View File
@@ -9,6 +9,13 @@
*
* The one static importer allowed is the OpenAPI registry, which only the
* spec generator loads.
*
* The schema is composed from one slice per domain (`<domain>-backup-schema.ts`
* beside the domain's export and restore, the shared primitives in
* `backup-schema-kit.ts`). A slice builds its part of the 12 MB the moment it
* is evaluated, so the same rule holds for every slice: it is reached through
* `src/lib/validations/backup.ts` and from other slices, and from nothing
* else, however its domain module is used.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
@@ -37,27 +44,54 @@ const STATIC_IMPORT =
/^import\s+(type\s+)?([^;]*?)\s+from\s+["']@\/lib\/validations\/backup["']/gm;
const DYNAMIC_IMPORT = /import\(\s*["']@\/lib\/validations\/backup["']\s*\)/g;
/** `import … from` a schema slice or the kit, capturing what precedes it. */
const SLICE_IMPORT =
/^import\s+(type\s+)?([^;]*?)\s+from\s+["']@\/lib\/(?:export\/[\w-]+-backup-schema|export\/backup-schema-kit|cycle\/backup-schema)["']/gm;
/** A slice module itself, by its path under `src/`. */
function isSlice(rel: string): boolean {
return (
/^lib\/export\/[\w-]+-backup-schema\.ts$/.test(rel) ||
rel === "lib/export/backup-schema-kit.ts" ||
rel === "lib/cycle/backup-schema.ts"
);
}
function isTypeOnly(match: RegExpMatchArray): boolean {
return (
match[1] !== undefined ||
(/^\{[^}]*\}$/.test(match[2]) &&
match[2]
.slice(1, -1)
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.every((s) => s.startsWith("type ")))
);
}
describe("the backup payload schema loads on first use", () => {
const files = sourceFiles(ROOT);
const staticValueImporters: string[] = [];
let dynamicImports = 0;
let typeImports = 0;
const sliceFiles: string[] = [];
const sliceValueImporters: string[] = [];
let slicesComposed = 0;
for (const file of files) {
const rel = relative(ROOT, file);
if (rel === "lib/validations/backup.ts") continue;
const text = readFileSync(file, "utf8");
if (isSlice(rel)) sliceFiles.push(rel);
for (const match of text.matchAll(SLICE_IMPORT)) {
if (isTypeOnly(match)) continue;
if (rel === "lib/validations/backup.ts") slicesComposed++;
else if (!isSlice(rel)) sliceValueImporters.push(rel);
}
if (rel === "lib/validations/backup.ts") continue;
for (const match of text.matchAll(STATIC_IMPORT)) {
const typeOnly =
match[1] !== undefined ||
(/^\{[^}]*\}$/.test(match[2]) &&
match[2]
.slice(1, -1)
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.every((s) => s.startsWith("type ")));
if (typeOnly) typeImports++;
if (isTypeOnly(match)) typeImports++;
else if (!ALLOWED_STATIC.has(rel)) staticValueImporters.push(rel);
}
dynamicImports += [...text.matchAll(DYNAMIC_IMPORT)].length;
@@ -67,6 +101,15 @@ describe("the backup payload schema loads on first use", () => {
expect(staticValueImporters).toEqual([]);
});
it("reaches no schema slice except through the payload schema", () => {
expect(sliceValueImporters).toEqual([]);
});
it("finds the slices and their composition (no vacuous pass)", () => {
expect(sliceFiles.length).toBeGreaterThanOrEqual(29);
expect(slicesComposed).toBeGreaterThanOrEqual(28);
});
it("finds the loaders it guards (an empty match set would pass vacuously)", () => {
// upload, restore, preview (store + summary route), download, streamed reader
expect(dynamicImports).toBeGreaterThanOrEqual(5);
@@ -0,0 +1,50 @@
{
"$comment": "Runtime import cycles under src/: the back-edge count and the strongly connected components. Maintained by src/__tests__/import-cycle-guard.test.ts.",
"cycles": 13,
"components": [
[
"components/command-palette/command-palette.tsx",
"components/command-palette/palette-store.ts",
"components/layout/global-shortcuts.tsx"
],
[
"lib/analytics/compliance.ts",
"lib/analytics/compliance/display.ts",
"lib/analytics/compliance/ledger.ts",
"lib/analytics/compliance/occurrences.ts",
"lib/analytics/compliance/windows.ts",
"lib/medications/scheduling/band-minter.ts",
"lib/medications/scheduling/cadence.ts",
"lib/medications/scheduling/compliance.ts",
"lib/medications/scheduling/schedule-eras.ts"
],
[
"lib/auth/audit.ts",
"lib/geo.ts",
"lib/integrations/oauth-kit/connect-target.ts",
"lib/jobs/offhost-purge.ts",
"lib/managed-profiles/lifecycle.ts",
"lib/notifications/channel-state.ts",
"lib/notifications/delivery-identity.ts",
"lib/notifications/dispatch-localised.ts",
"lib/notifications/dispatcher.ts",
"lib/sharing/grants.ts"
],
[
"lib/cycle/gate.ts",
"lib/modules/gate.ts"
],
[
"lib/insights/features.ts",
"lib/insights/signals-of-day.ts"
],
[
"lib/insights/mood-aggregates.ts",
"lib/insights/mood-patterns.ts"
],
[
"lib/jobs/boss-instance.ts",
"lib/jobs/boss-ready.ts"
]
]
}
Loaded 100 of 929 files, more files were not shown because too many files have changed in this diff. Show more