mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Merge pull request #1252 from MBombeck/release/v1.43.7
release: v1.43.7
This commit is contained in:
1892 files changed
+35353
-11256
No files matched your search
@@ -1,12 +1,12 @@
|
||||
name: Translation feedback
|
||||
description: Report a wording, grammar, or register problem in HealthLog's AI-initial locales (FR / ES / IT / PL).
|
||||
description: Report a wording, grammar, or register problem in HealthLog's AI-initial locales (FR / ES / IT / PL / KO / NL).
|
||||
title: "[Translation] "
|
||||
labels: ["i18n", "translation-feedback"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
HealthLog ships German and English as maintainer-curated translations. French, Spanish, Italian, and Polish 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, 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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -20,6 +20,8 @@ body:
|
||||
- Spanish (es)
|
||||
- Italian (it)
|
||||
- Polish (pl)
|
||||
- Korean (ko)
|
||||
- Dutch (nl)
|
||||
- Other (specify under "Surface")
|
||||
validations:
|
||||
required: true
|
||||
|
||||
+146
@@ -1,5 +1,151 @@
|
||||
# Changelog
|
||||
|
||||
## [1.43.7] — 2026-10-20
|
||||
|
||||
### Added
|
||||
|
||||
- **Dutch.** HealthLog speaks Dutch now, the eighth language, translated and
|
||||
contributed by @robdegraaf (#1246). Friends in the Netherlands are always
|
||||
welcome here. Cheers with a Grolsch! 🍺
|
||||
- **Readings you enter here count for Apple Health freshness.** Weight, blood
|
||||
pressure and glucose are no longer called quiet while you keep entering them
|
||||
in HealthLog for the phone to pick up, and the card shows when you last did
|
||||
(`lastEnteredHereAt`, Refs #1247).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Error states say what happened.** When a session has ended, a card offers
|
||||
to sign in again instead of a retry that cannot work; when there were too
|
||||
many requests just now, it says so without a pointless retry; a section you
|
||||
are not allowed to see on a shared record stays quiet. Some thirty cards that
|
||||
used to show a bare red line, a "still learning" note, an endless skeleton
|
||||
or nothing at all when their data failed to load now say so and offer a
|
||||
retry, and charts follow the same rule. Every read failure goes through the
|
||||
same row, card and chart state, deciding by the error code first and the
|
||||
status second.
|
||||
- **A short database outage no longer signs you out.** A session that could not
|
||||
be checked because the database was unreachable, out of connections or
|
||||
otherwise failing answers 503 (`auth.session.unavailable`) and the client
|
||||
keeps its credential and tries again. A malformed session cookie is treated
|
||||
as signed out (401) without a database lookup.
|
||||
- **A nap is not a night, but it is sleep.** A day whose only sleep is a short
|
||||
daytime nap has no main night (`main: null` on `/api/sleep/night`). A day's
|
||||
sleep in the list, the export and the correlations counts its naps, the same
|
||||
total the chart and the sleep score show; the main night alone is
|
||||
`mainAsleepMinutes`. The night view opened without a date shows the last
|
||||
night with main sleep. The sleep list, the night view, the chart and the
|
||||
sleep score name the same day for every night. A REM segment of zero minutes
|
||||
no longer drops the night around it.
|
||||
- **Deleted entries stay deleted for the AI.** Deleted mood entries and
|
||||
readings no longer shape the status texts written about you, and the general
|
||||
overview looks at every metric again instead of the newest few thousand rows
|
||||
of the densest one.
|
||||
- **Days logged for a chronic condition count** in the symptom correlations.
|
||||
- **Inviting someone whose name or e-mail matches no account** says so
|
||||
(`sharing.invite.no_account`) and is never confused with a profile you no
|
||||
longer look after.
|
||||
- **Old nights open, and "All" means all.** The night view answers any date,
|
||||
however old, and the sleep series reaches back to the first night. The sleep
|
||||
status read the last few weeks of stages; it now reads the last year of
|
||||
nights.
|
||||
- **Days are your days.** The dashboard streak and the day a dose counts for
|
||||
were placed on the wrong side of midnight for readings close to it; both
|
||||
follow the profile timezone now. Skipping a dose from Telegram snoozes until
|
||||
the end of your own day, not the server's.
|
||||
- **One number for one thing.** The spread of a series is the same in the app,
|
||||
the API, MCP and AI tools. Correlations on the insights overview and in AI
|
||||
data appear only when they are statistically sound (at least 20 pairs,
|
||||
p < 0.05), as everywhere else; below that the value is `null`. A chronic
|
||||
condition no longer fills the symptom series with zeros.
|
||||
- **Medication adherence on the cadence chips** counts pauses, courses and
|
||||
missed doses like the detail page; a week's pause read 77 % instead of 100 %.
|
||||
- **A workout Apple Health sends twice is stored once,** and every workout
|
||||
reader uses the same source order.
|
||||
- **Provider histories do not end early without saying so.** A WHOOP, Oura,
|
||||
Withings, Fitbit or Strava history that stops at its page limit is no longer
|
||||
marked as fully imported. A Strava history of more than about 4,000
|
||||
activities imports in stages from where the last stage stopped, one stage per
|
||||
rate window, instead of re-reading the newest activities after every
|
||||
restart. WHOOP and Oura no longer read the first page twice when a list ends
|
||||
with an empty page token. Imported values from every provider and from
|
||||
Health Connect are rounded the same way, with halves rounded up (a weight of
|
||||
72.345 kg now stores as 72.35, not 72.34).
|
||||
- **A long Google Health backfill keeps its place.** Only attempts that end at
|
||||
the same edge count toward the final end, and a restart during the 24-hour
|
||||
wait no longer starts a second chain.
|
||||
- **The recovery page** says in one sentence why it is still empty, decided on
|
||||
the server (`recoveryEmpty`), and always links to the integrations.
|
||||
- **Lists that stop at a limit say so** (`meta.truncated`, `meta.limit`) instead
|
||||
of passing the oldest loaded entry off as the beginning, and the web shows a
|
||||
note under each such list. The activity feed keeps its `data.truncated` for
|
||||
existing clients; it is deprecated in favour of `meta.truncated`.
|
||||
- Reconnecting Strava, even to a different account, reads that account's full
|
||||
history again instead of continuing where the previous one stopped.
|
||||
- The day view's "highest since" notes pick up a new or corrected reading right
|
||||
away.
|
||||
- **Old days and long histories are read whole.** The sleep summary covers the
|
||||
whole history, the pulse assessment reads at least a month of pulse instead
|
||||
of a few hours of a dense stream, and the mood fallback reads the same five
|
||||
years as the rollup path.
|
||||
- The coach's daily limit on follow-up questions resets at your own midnight,
|
||||
and the weather and air-quality refresh includes today east of UTC.
|
||||
- **Deleting a large account** no longer aborts after five seconds.
|
||||
- Workout dates follow your date format in the list, the detail and the
|
||||
dashboard tile. "Not enough nights yet" names the number actually needed.
|
||||
`groupBy=day` together with `valueMin`/`valueMax` answers 422 instead of
|
||||
ignoring the filter.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Faster reads.** The latest value per type is read through one bounded query
|
||||
instead of a sort over every reading (about 60 ms down to 1 to 2 ms on a large
|
||||
account), the dashboard summaries share one cache cell, and paging through the
|
||||
day view computes the two-year comparison once.
|
||||
- **One heavy job at a time per process.** Imports, restores, backups, rollup
|
||||
backfills, provider backfills and the nightly runs queue behind each other
|
||||
instead of stacking up in one heap. A job that finds the lane busy goes back
|
||||
to the queue and starts once it is free, so a backup waiting behind a large
|
||||
import no longer runs out of time while it waits. A long Google Health
|
||||
backfill makes room at its next slice boundary for a job waiting behind it,
|
||||
and a job that waits more than two hours is reported as a warning
|
||||
(`job.heavy_lane.waiting_long`). Building rollups for a large account no
|
||||
longer runs a second time when it takes longer than fifteen minutes. The six per-metric status
|
||||
crons are gone (the nightly batch already covers them), and a queue fed only
|
||||
by its cron polls every 30 seconds.
|
||||
- **`measurements` stays small.** An update that changes nothing keeps its
|
||||
timestamps, so it stays on its page and is not sent to the phone again; the
|
||||
table leaves room on each page and autovacuum starts at 5 % dead rows. A
|
||||
nightly check warns when dead rows or bloat grow past that (see
|
||||
`docs/ops/measurement-maintenance.md`).
|
||||
- **Migrations that wait for a lock no longer hold writers back or stop the
|
||||
boot.** `prisma migrate deploy` runs a migration file statement by
|
||||
statement, not in one transaction, so the lock-sensitive part of migration
|
||||
0412 runs as one block that gives up after ten seconds when another process
|
||||
holds `measurements`. The container then retries a migration marked safe to
|
||||
re-run, with a growing pause, instead of stopping at boot; any other
|
||||
migration failure still stops the boot. See "A migration that timed out on a
|
||||
lock" in `docs/ops/deploy.md`.
|
||||
- The day view's "highest since" notes cache fixed 90-day blocks and use less
|
||||
memory when paging back through older days.
|
||||
- An insights card whose refresh keeps failing stops polling after its usual
|
||||
number of attempts.
|
||||
- Failed provider syncs and refused uploads are logged as signals instead of
|
||||
audit entries; a provider's error text no longer lands in the audit log in
|
||||
plain text.
|
||||
- **The cached AI health report is encrypted at rest,** existing rows included.
|
||||
- Every API error carries a stable `meta.errorCode`; a route without its own
|
||||
code gets an `http.*` code from the status.
|
||||
- Logs group requests by route template (`/api/medications/[id]`) instead of by
|
||||
concrete path.
|
||||
- Failed inputs no longer fill the audit log; only attempts by someone with a
|
||||
grant appear there.
|
||||
- The browser bundle no longer pulls in server modules, and a test keeps it
|
||||
that way.
|
||||
- The Coach stops polling a document that will never be indexed.
|
||||
- On an out-of-memory crash the process writes a diagnostic report to stderr
|
||||
(`--report-on-fatalerror`, without environment or network details), and the
|
||||
heap monitor adds a 95 % tier.
|
||||
|
||||
## [1.43.6] — 2026-10-19
|
||||
|
||||
### Fixed
|
||||
|
||||
+2
-2
@@ -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`), and Korean (`ko`) 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`), 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.
|
||||
|
||||
### Improving an existing locale
|
||||
|
||||
@@ -136,7 +136,7 @@ To suggest a fix or improvement:
|
||||
|
||||
### Adding a new locale
|
||||
|
||||
If you want to introduce a locale that isn't shipped (e.g. `nl`, `pt-BR`, `cs`):
|
||||
If you want to introduce a locale that isn't shipped (e.g. `pt-BR`, `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`.
|
||||
|
||||
+15
-3
@@ -76,6 +76,15 @@ ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV TZ=Europe/Berlin
|
||||
|
||||
# When the heap runs out, Node writes a diagnostic report (the JavaScript
|
||||
# and native stacks, heap spaces, libuv handles) to stderr, where
|
||||
# `docker compose logs` and Loki keep it; without it the log holds one
|
||||
# line. Environment variables and network interfaces are left out, so no
|
||||
# key, password or address from the environment reaches the log. An
|
||||
# operator who sets NODE_OPTIONS replaces this value and should carry these
|
||||
# flags over (docs/ops/heap-diagnostics.md).
|
||||
ENV NODE_OPTIONS="--report-on-fatalerror --report-filename=stderr --report-exclude-env --report-exclude-network"
|
||||
|
||||
# v1.4.43 B11 — forward NEXT_PUBLIC_APP_VERSION from the builder stage
|
||||
# into the runner so /api/version reads the build-arg value instead of
|
||||
# the package.json fallback. The variable is keyed to the CI tag ref
|
||||
@@ -203,11 +212,13 @@ COPY --from=builder /app/src/generated ./src/generated
|
||||
# application graph (`@/…`, the generated Prisma client) run from a source
|
||||
# checkout instead. `src/__tests__/container-scripts-ship-guard.test.ts` holds
|
||||
# the runbooks and the entrypoint against this list. `pending-migrations.mjs`
|
||||
# is the entrypoint's own: it logs the pending migration count before
|
||||
# `migrate deploy`. Root-owned on purpose: the app user can run them but not
|
||||
# and `migrate-deploy.mjs` are the entrypoint's own: the first logs the
|
||||
# pending migration count, the second runs `migrate deploy` with its
|
||||
# lock-timeout retry. Root-owned on purpose: the app user can run them but not
|
||||
# rewrite them.
|
||||
COPY --from=builder /app/scripts/reset-password.mjs ./scripts/reset-password.mjs
|
||||
COPY --from=builder /app/scripts/pending-migrations.mjs ./scripts/pending-migrations.mjs
|
||||
COPY --from=builder /app/scripts/migrate-deploy.mjs ./scripts/migrate-deploy.mjs
|
||||
COPY --from=builder /app/src/lib/auth/argon2-params.mjs ./src/lib/auth/argon2-params.mjs
|
||||
COPY --from=builder /app/scripts/repair-intake-anomalies.ts ./scripts/repair-intake-anomalies.ts
|
||||
# Prove the reset CLI's imports resolve in this tree: with every import found
|
||||
@@ -215,7 +226,8 @@ COPY --from=builder /app/scripts/repair-intake-anomalies.ts ./scripts/repair-int
|
||||
RUN node scripts/reset-password.mjs 2>&1 | grep -q "reset-password: usage:"
|
||||
# The boot notice parses, and the `pg` it loads on a real boot resolves here.
|
||||
RUN env -u DATABASE_URL node scripts/pending-migrations.mjs && \
|
||||
node --input-type=module -e "await import('pg')"
|
||||
node --input-type=module -e "await import('pg')" && \
|
||||
node --input-type=module -e "await import('./scripts/migrate-deploy.mjs')"
|
||||
|
||||
# Install the migration CLI, its config dependency, and a pinned launcher for
|
||||
# the maintenance scripts already shipped in the standalone tree. The Prisma
|
||||
|
||||
@@ -60,7 +60,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, and Korean 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, and Dutch end to end.
|
||||
|
||||
The full feature tour, integration guides, and API reference live at [docs.healthlog.dev](https://docs.healthlog.dev).
|
||||
|
||||
|
||||
+4
-3
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
|
||||
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
|
||||
"routeBaselineKbGz": {
|
||||
"/page": 355,
|
||||
"/insights/page": 304,
|
||||
@@ -8,6 +8,7 @@
|
||||
"/settings/[section]/page": 307
|
||||
},
|
||||
"routeDriftAllowanceKbGz": 20,
|
||||
"totalClientKbGz": 5000,
|
||||
"maxRechartsChunks": 1
|
||||
"totalClientKbGz": 3700,
|
||||
"maxRechartsChunks": 1,
|
||||
"maxCatalogKbGz": 210
|
||||
}
|
||||
@@ -88,9 +88,12 @@ echo "HealthLog: Database is reachable."
|
||||
# Say how many migrations this image is about to apply, with a reminder to
|
||||
# back up first. Reporting only: it never stops the boot.
|
||||
node scripts/pending-migrations.mjs || true
|
||||
# `migrate-deploy.mjs` runs `prisma migrate deploy` and retries only a
|
||||
# migration that timed out on a lock and is marked safe to re-run; any other
|
||||
# failure stops the boot with Prisma's exit code (docs/ops/deploy.md).
|
||||
echo "HealthLog: Running database migrations..."
|
||||
NODE_PATH=/opt/prisma-cli/node_modules \
|
||||
node /opt/prisma-cli/node_modules/prisma/build/index.js migrate deploy
|
||||
node scripts/migrate-deploy.mjs
|
||||
echo "HealthLog: Migrations complete."
|
||||
|
||||
# ── Start application ────────────────────────────────────────
|
||||
|
||||
+257
-156
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.43.6
|
||||
version: 1.43.7
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -80,8 +80,10 @@ paths:
|
||||
403 refusals carry `auth.scope.insufficient` (the credential is valid but its scope does not reach this
|
||||
route, including any Bearer token against an admin surface) and `auth.admin.required` (a cookie session that
|
||||
is not an admin's). A route that checks a credential of its own — sign-in, the MFA challenge exchange, a
|
||||
password confirmation — may still answer 401 with prose and no code, so a missing `meta.errorCode` means the
|
||||
route refused the credential it was handed, not that the response is malformed."
|
||||
password confirmation — may answer 401 with the generic `http.unauthenticated`, which means the route
|
||||
refused the credential it was handed. A session that could not be checked because the session store was
|
||||
unreachable is NOT a 401: it answers 503 with `auth.session.unavailable`, and the client keeps its
|
||||
credential and retries."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -3514,9 +3516,10 @@ paths:
|
||||
$ref: "#/components/schemas/ApiTokenRevokeEnvelope"
|
||||
"401": *a1
|
||||
"403":
|
||||
description: The operator has switched the API off instance-wide (`AppSettings.apiGlobal`). No `meta.errorCode`; nothing
|
||||
was revoked. The sibling list is deliberately not gated this way — a token stays live while the switch is
|
||||
off, so its owner can still see it here even though this call will not kill it.
|
||||
description: The operator has switched the API off instance-wide (`AppSettings.apiGlobal`). `meta.errorCode` is the
|
||||
status default `http.forbidden`; nothing was revoked. The sibling list is deliberately not gated this way —
|
||||
a token stays live while the switch is off, so its owner can still see it here even though this call will
|
||||
not kill it.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -4203,8 +4206,13 @@ paths:
|
||||
|
||||
|
||||
With `groupBy=day` on a cumulative type the rows are one sum per local day, and `limit` / `offset` page over
|
||||
those days; every returned day is complete. `type=SLEEP_DURATION` returns one row per night, paged the same way
|
||||
over nights. On both, `meta.total` counts the days or nights in the filtered window. A day-sum page takes
|
||||
those days; every returned day is complete, which is why `valueMin` / `valueMax` (a filter on single readings)
|
||||
are refused with 422 beside `groupBy=day`. `type=SLEEP_DURATION` returns one row per night, paged the same way
|
||||
over nights; every night of the page has a row, and `value` is the day's time asleep, the main night plus its
|
||||
naps (the same total `/api/measurements/series?kind=sleep` and the sleep score read), with the main night alone
|
||||
in `mainAsleepMinutes` and the naps in `napCount` / `napAsleepMinutes`; a day whose only sleep is daytime naps
|
||||
carries the naps' minutes and `mainAsleepMinutes: null`. Its `dayKey` is the same day `/api/sleep/night?date=`
|
||||
answers for. On both, `meta.total` counts the days or nights in the filtered window. A day-sum page takes
|
||||
`limit` as asked (up to the list's 5000); a sleep page holds at most 1000 nights, and `meta.limit` echoes the
|
||||
value applied.
|
||||
parameters:
|
||||
@@ -4727,16 +4735,18 @@ paths:
|
||||
source's stage segments (each with an absolute start/end), the per-stage breakdown, asleep/in-bed/awake totals,
|
||||
the mid-sleep awakenings count, and same-wake-day naps surfaced separately. Session-clustered, keyed by the
|
||||
local wake day, collapsed to one source via the sleep priority ladder so two sources never overlay. `date`
|
||||
omitted returns the most recent night. A read-only view over existing per-stage SLEEP_DURATION rows — no schema,
|
||||
no new measurement type."
|
||||
omitted returns the most recent night with a main sleep, however old; later days of naps alone are stepped over
|
||||
and stay reachable by `date`. An account that has only ever napped gets its latest day of naps. Any past date is
|
||||
answered, with no window cut. A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new
|
||||
measurement type."
|
||||
parameters:
|
||||
- in: query
|
||||
name: date
|
||||
schema:
|
||||
description: Wake-day key (YYYY-MM-DD). Omit for the most recent night.
|
||||
description: Wake-day key (YYYY-MM-DD). Omit for the most recent night with a main sleep.
|
||||
type: string
|
||||
pattern: ^\d{4}-\d{2}-\d{2}$
|
||||
description: Wake-day key (YYYY-MM-DD). Omit for the most recent night.
|
||||
description: Wake-day key (YYYY-MM-DD). Omit for the most recent night with a main sleep.
|
||||
responses:
|
||||
"200":
|
||||
description: Reconstructed night.
|
||||
@@ -7701,7 +7711,7 @@ paths:
|
||||
`health_score_config.invalid` — the body failed validation, with the per-issue list under `details.issues`;
|
||||
`invalid_base_updated_at` — `baseUpdatedAt` was present but unparseable, `null` included, which is rejected
|
||||
rather than falling back to the unconditional write. A body that is not JSON at all returns the bare message
|
||||
`health-score-config.body.invalid_json` with no errorCode."
|
||||
`health-score-config.body.invalid_json` with the status default `meta.errorCode: http.invalid`."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -21427,7 +21437,9 @@ paths:
|
||||
per day with a count, rather than one per request. `retentionDays` rides the response because audit history is
|
||||
purged on a schedule the operator configures: a view that showed the rows without the window would imply a
|
||||
completeness it does not have, and clients must state the number the server sends rather than assume the 365-day
|
||||
default. Not delegable: a grant is not an introduction to the other people in the household."
|
||||
default. Whether the list stopped at its row ceiling is `meta.truncated`; the `truncated` field in `data`
|
||||
carries the same value and is deprecated. Not delegable: a grant is not an introduction to the other people in
|
||||
the household."
|
||||
responses:
|
||||
"200":
|
||||
description: The activity, and how far back it can see.
|
||||
@@ -21514,7 +21526,7 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"404":
|
||||
description: No account on this instance carries that identifier.
|
||||
description: "No account on this instance carries that identifier (`meta.errorCode: sharing.invite.no_account`)."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -21705,8 +21717,8 @@ paths:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a4
|
||||
"429":
|
||||
description: More than ten profiles from one caller within the hour. No error code — rate-limit refusals carry none
|
||||
anywhere in this API; branch on the status. Nothing was created.
|
||||
description: "More than ten profiles from one caller within the hour (`meta.errorCode: http.rate_limited`, the status
|
||||
default; a 429 has one meaning here, so the status alone is enough to branch on). Nothing was created."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -21789,8 +21801,9 @@ paths:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a4
|
||||
"429":
|
||||
description: More than ten edits from one caller within the hour — the ceiling creation carries. No error code; branch
|
||||
on the status. Nothing was changed, and the refusal lands before the record named in the path is looked at.
|
||||
description: "More than ten edits from one caller within the hour — the ceiling creation carries. `meta.errorCode:
|
||||
http.rate_limited`, the status default. Nothing was changed, and the refusal lands before the record named
|
||||
in the path is looked at."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -21875,11 +21888,10 @@ paths:
|
||||
summary: Invite another Guardian
|
||||
description: "Offers somebody else a share in looking after this profile, at MANAGE over the whole record. Cookie-only
|
||||
and step-up gated, like every act in this family — the read beside it is not. **Resolve refusals on
|
||||
`meta.errorCode` first and fall back to the status only for the two arms that carry none**, because this route
|
||||
answers 404 twice and the codeless one is the less serious of the two: an identifier that matches no account
|
||||
carries no code, while `managed_profile.not_found` means the caller is not a Guardian of that profile at all. A
|
||||
client branching on the status first would tell somebody to check a spelling when the real answer is that the
|
||||
record is no longer theirs to administer."
|
||||
`meta.errorCode`, not on the status**, because this route answers 404 twice and the two need different next
|
||||
steps: `sharing.invite.no_account` means no account carries that identifier, while `managed_profile.not_found`
|
||||
means the caller is not a Guardian of that profile at all. A client branching on the status would tell somebody
|
||||
to check a spelling when the real answer is that the record is no longer theirs to administer."
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@@ -21906,11 +21918,11 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"404":
|
||||
description: "Two refusals share this status and only one carries a code. **No `meta.errorCode`**: no account on this
|
||||
instance carries that identifier — the same deliberate disclosure to an authenticated caller that `POST
|
||||
/api/account/grants` makes, and codeless there too, so this family does not answer one way here and another
|
||||
way one page over. `meta.errorCode: managed_profile.not_found`: no such managed profile, or the caller is
|
||||
not one of its Guardians."
|
||||
description: "Two refusals share this status, each with its own code. `meta.errorCode: sharing.invite.no_account`: no
|
||||
account on this instance carries that identifier — the same deliberate disclosure to an authenticated caller
|
||||
that `POST /api/account/grants` makes, with the same code, so this family does not answer one way here and
|
||||
another way one page over. `meta.errorCode: managed_profile.not_found`: no such managed profile, or the
|
||||
caller is not one of its Guardians."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -21933,7 +21945,8 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"429":
|
||||
description: More than ten invitations from one caller within the hour. No error code, as above; branch on the status.
|
||||
description: "More than ten invitations from one caller within the hour (`meta.errorCode: http.rate_limited`, the status
|
||||
default)."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -25902,6 +25915,7 @@ components:
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
- nl
|
||||
practiceName:
|
||||
type: string
|
||||
maxLength: 120
|
||||
@@ -28360,6 +28374,7 @@ components:
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
- nl
|
||||
today:
|
||||
description: "Optional override of the reference date used to resolve relative phrases (\"tomorrow\", \"next Monday\").
|
||||
Format: `YYYY-MM-DD`. Defaults to the account's own day in its time zone."
|
||||
@@ -29310,6 +29325,7 @@ components:
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
- nl
|
||||
description: One of the interface locales this build ships.
|
||||
required:
|
||||
- locale
|
||||
@@ -34599,6 +34615,7 @@ components:
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
- nl
|
||||
timezone:
|
||||
type: string
|
||||
minLength: 1
|
||||
@@ -34653,6 +34670,7 @@ components:
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
- nl
|
||||
timezone:
|
||||
type: string
|
||||
minLength: 1
|
||||
@@ -34870,6 +34888,7 @@ components:
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
- nl
|
||||
- type: "null"
|
||||
timezone:
|
||||
type: string
|
||||
@@ -36095,15 +36114,19 @@ components:
|
||||
description: "Stable machine code for this refusal. Branch on it rather than on `error`, which is prose and may be
|
||||
reworded. Every code the API emits today is enumerated below, grouped by the surface that emits it; four
|
||||
naming conventions coexist and none of them will be renamed, because a code is a wire value a shipped
|
||||
client branches on. Treat an unlisted code the way you would treat an unlisted enum member — as a
|
||||
refusal you do not recognise, not as a malformed response — since the list grows with the surfaces.
|
||||
Three families are outside it on purpose: `assistant.disabled.<switch>` is built from a template so the
|
||||
last segment is open — it is one of `coach`, `briefing`, `insightStatus`, `documentAi` and `enabled`,
|
||||
and `assistant.disabled.coach` is the one the native client names; the integration-probe classes
|
||||
(`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed` and the
|
||||
per-provider additions) are enumerated in each `/test` operation instead, where the differences can be
|
||||
stated; and a 401 raised by a route checking a credential of its own may carry no code at all. ai —
|
||||
ai.budget.exceeded, ai.provider.none, ai.record.notPermitted, ai.unavailable, ai.usage.aboveCeiling,
|
||||
client branches on. Every error envelope carries a code: when the route has no reason of its own to
|
||||
name, the code is the status's generic one from the `http` family (`http.not_found` for a 404,
|
||||
`http.internal` for a 500, `http.client_error` / `http.server_error` for a status without its own
|
||||
entry), so `http.*` says which kind of refusal it was and never why. Treat an unlisted code the way you
|
||||
would treat an unlisted enum member — as a refusal you do not recognise, not as a malformed response —
|
||||
since the list grows with the surfaces. Three families are outside it on purpose:
|
||||
`assistant.disabled.<switch>` is built from a template so the last segment is open — it is one of
|
||||
`coach`, `briefing`, `insightStatus`, `documentAi` and `enabled`, and `assistant.disabled.coach` is the
|
||||
one the native client names; the integration-probe classes (`credentials_rejected`, `rate_limited`,
|
||||
`upstream_error`, `timeout`, `connection_failed` and the per-provider additions) are enumerated in each
|
||||
`/test` operation instead, where the differences can be stated; and a 401 raised by a route checking a
|
||||
credential of its own carries the generic `http.unauthenticated`. ai — ai.budget.exceeded,
|
||||
ai.provider.none, ai.record.notPermitted, ai.unavailable, ai.usage.aboveCeiling,
|
||||
ai.usage.defaultAboveCeiling, ai.usage.invalid, ai.usage.levelTooLow, ai.usage.rateLimited; ai_provider
|
||||
— ai_provider.invalid, ai_provider.no_fields; aiRuns — aiRuns.failed, aiRuns.notFound, aiRuns.timedOut,
|
||||
aiRuns.workerUnavailable; allergy — allergy.invalid; analytics — analytics.invalid_query; anamnesis —
|
||||
@@ -36112,35 +36135,35 @@ components:
|
||||
anamnesis.lifeConfirm.invalid, anamnesis.lifeSections.invalid; auth — auth.admin.required,
|
||||
auth.already_authenticated, auth.mfa.code_invalid, auth.missing, auth.refresh.invalid,
|
||||
auth.refresh.reuse, auth.refresh.revoked, auth.reproof.failed, auth.reproof.required,
|
||||
auth.reproof.too_weak, auth.scope.insufficient, auth.session.insecure_transport, auth.stepup.required,
|
||||
auth.token.expired, auth.token.invalid; backup — backup.foreign_reference, backup.key.missing,
|
||||
backup.payload.undecryptable, backup.restore.active, backup.section.missing, backup.upload.too_large;
|
||||
biomarkers — biomarkers.unit.locked; body-site — body-site.invalid; coach — coach.budget.exceeded,
|
||||
coach.conversation.invalidTitle, coach.conversation.notFound, coach.fenced.attachmentConflict,
|
||||
coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed,
|
||||
coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid,
|
||||
coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape;
|
||||
consent — consent.ai.confirmRequired, consent.ai.required; cycle — cycle.bulk.invalid,
|
||||
cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, cycle.cycles.invalid,
|
||||
cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, cycle.period.invalid,
|
||||
cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — cycle-prefs.body.invalid_json,
|
||||
cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; dawarich —
|
||||
dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid, dawarich.not_connected,
|
||||
dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo — demo.readOnly; documents —
|
||||
documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed,
|
||||
documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded,
|
||||
documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound,
|
||||
documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending,
|
||||
documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, documents.inbound.fileType,
|
||||
documents.inbound.invalidMetadata, documents.inbound.invalidQuery, documents.inbound.invalidUpdate,
|
||||
documents.inbound.localOcrDisabled, documents.inbound.notFound, documents.inbound.notIndexed,
|
||||
documents.inbound.pdfNeedsAnthropic, documents.inbound.providerUnsupported,
|
||||
documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate,
|
||||
documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, documents.inbound.uploadBusy,
|
||||
documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, documents.sources.authRefused,
|
||||
documents.sources.badResponse, documents.sources.browserOnly, documents.sources.invalidAddress,
|
||||
documents.sources.linkTargetNotFound, documents.sources.notConnected, documents.sources.notFound,
|
||||
documents.sources.organizationRequired, documents.sources.originNotAllowed,
|
||||
auth.reproof.too_weak, auth.scope.insufficient, auth.session.insecure_transport,
|
||||
auth.session.unavailable, auth.stepup.required, auth.token.expired, auth.token.invalid; backup —
|
||||
backup.foreign_reference, backup.key.missing, backup.payload.undecryptable, backup.restore.active,
|
||||
backup.section.missing, backup.upload.too_large; biomarkers — biomarkers.unit.locked; body-site —
|
||||
body-site.invalid; coach — coach.budget.exceeded, coach.conversation.invalidTitle,
|
||||
coach.conversation.notFound, coach.fenced.attachmentConflict, coach.fenced.attachmentLimit,
|
||||
coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired,
|
||||
coach.fenced.attachmentUnavailable, coach.fenced.invalid, coach.request.invalid; coach-prefs —
|
||||
coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; consent — consent.ai.confirmRequired,
|
||||
consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid,
|
||||
cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid,
|
||||
cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit;
|
||||
cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach —
|
||||
disable-coach.body.invalid_json; dawarich — dawarich.credentials_rejected, dawarich.home_place_unknown,
|
||||
dawarich.invalid, dawarich.not_connected, dawarich.rate_limited, dawarich.unreachable; day —
|
||||
day.invalid; demo — demo.readOnly; documents — documents.chat.conversationNotFound,
|
||||
documents.chat.invalid, documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed,
|
||||
documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound,
|
||||
documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound,
|
||||
documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge,
|
||||
documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery,
|
||||
documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notFound,
|
||||
documents.inbound.notIndexed, documents.inbound.pdfNeedsAnthropic,
|
||||
documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited,
|
||||
documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.sourceAliasLimit,
|
||||
documents.inbound.uploadBusy, documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound,
|
||||
documents.sources.authRefused, documents.sources.badResponse, documents.sources.browserOnly,
|
||||
documents.sources.invalidAddress, documents.sources.linkTargetNotFound, documents.sources.notConnected,
|
||||
documents.sources.notFound, documents.sources.organizationRequired, documents.sources.originNotAllowed,
|
||||
documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected,
|
||||
documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable,
|
||||
documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid,
|
||||
@@ -36150,18 +36173,22 @@ components:
|
||||
environment.range_too_large, environment.travel.not_found, environment.travel_suggestion.not_found;
|
||||
export — export.selection.empty, export.selection.unknown_leaf; familyHistory — familyHistory.invalid;
|
||||
feedback — feedback.body.invalid; health_score_config — health_score_config.invalid,
|
||||
health_score_config.too_narrow; illness — illness.day-log.invalid, illness.disabled,
|
||||
illness.episode.chronic-no-resolve, illness.episode.invalid, illness.episode.invalid-window,
|
||||
illness.episode.parent-not-found, illness.insights.invalid; import — import.apple_health.busy,
|
||||
import.write_failed; ingest — ingest.rate_limited; insights — insights.generate.budgetExceeded,
|
||||
insights.generate.outboundScreened, insights.generate.profileScopeChanged,
|
||||
insights.generate.scopeChanged; labs — labs.biomarker.alias.taken, labs.biomarker.create.invalid,
|
||||
labs.biomarker.duplicate, labs.biomarker.merge.invalid, labs.biomarker.merge.unitMismatch,
|
||||
labs.biomarker.notFound, labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid,
|
||||
labs.create.invalid, labs.link.invalid, labs.ocr.budgetExceeded, labs.ocr.extractFailed,
|
||||
labs.ocr.fileTooLarge, labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic,
|
||||
labs.ocr.providerUnsupported, labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound,
|
||||
labs.unit.mismatch, labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected,
|
||||
health_score_config.too_narrow; http — http.bad_gateway, http.bad_request, http.client_error,
|
||||
http.conflict, http.forbidden, http.gateway_timeout, http.gone, http.internal, http.invalid,
|
||||
http.method_not_allowed, http.not_found, http.payload_too_large, http.rate_limited, http.server_error,
|
||||
http.unauthenticated, http.unavailable, http.unsupported_media_type; illness — illness.day-log.invalid,
|
||||
illness.disabled, illness.episode.chronic-no-resolve, illness.episode.invalid,
|
||||
illness.episode.invalid-window, illness.episode.parent-not-found, illness.insights.invalid; import —
|
||||
import.apple_health.busy, import.write_failed; ingest — ingest.rate_limited; insights —
|
||||
insights.generate.budgetExceeded, insights.generate.outboundScreened,
|
||||
insights.generate.profileScopeChanged, insights.generate.scopeChanged; labs —
|
||||
labs.biomarker.alias.taken, labs.biomarker.create.invalid, labs.biomarker.duplicate,
|
||||
labs.biomarker.merge.invalid, labs.biomarker.merge.unitMismatch, labs.biomarker.notFound,
|
||||
labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, labs.create.invalid,
|
||||
labs.link.invalid, labs.ocr.budgetExceeded, labs.ocr.extractFailed, labs.ocr.fileTooLarge,
|
||||
labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, labs.ocr.providerUnsupported,
|
||||
labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound, labs.unit.mismatch,
|
||||
labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected,
|
||||
labs.update.qualitativeExpected, labs.update.referenceRangeInvalid,
|
||||
labs.update.sourceReferenceRangeInvalid; life_event — life_event.invalid, life_event.not_found;
|
||||
managed_profile — managed_profile.guardian.duplicate, managed_profile.guardian.managed_invitee,
|
||||
@@ -36208,9 +36235,9 @@ components:
|
||||
share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing —
|
||||
sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked,
|
||||
sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope,
|
||||
sharing.invite.manage_browser_only, sharing.invite.refused, sharing.invite.self, sharing.not_permitted,
|
||||
sharing.renounce.already_ended, sharing.renounce.not_found, sharing.revoke.already_ended,
|
||||
sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid,
|
||||
sharing.invite.manage_browser_only, sharing.invite.no_account, sharing.invite.refused,
|
||||
sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, sharing.renounce.not_found,
|
||||
sharing.revoke.already_ended, sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid,
|
||||
sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json,
|
||||
source-priority.body.invalid_shape; symptoms — symptoms.definition.limitReached,
|
||||
symptoms.definition.notFound, symptoms.episode.notOpen, symptoms.event.notFound, symptoms.invalid;
|
||||
@@ -36258,10 +36285,11 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
description: "Standard error response: `data` is null, `error` is human prose. `meta.errorCode` carries the stable
|
||||
machine code where one exists, and `meta` may carry further per-refusal context. A 422 from the multi-issue
|
||||
validator additionally carries `details.issues` — every field the request got wrong, so a client can fix them in
|
||||
one round-trip."
|
||||
description: "Standard error response: `data` is null, `error` is human prose. `meta.errorCode` always carries a stable
|
||||
machine code — the route's own where it names one, otherwise the status's generic `http.*` code —
|
||||
`meta.requestId` names the request for a support conversation, and `meta` may carry further per-refusal context.
|
||||
A 422 from the multi-issue validator additionally carries `details.issues` — every field the request got wrong,
|
||||
so a client can fix them in one round-trip."
|
||||
ValidationIssue:
|
||||
type: object
|
||||
properties:
|
||||
@@ -37246,15 +37274,32 @@ components:
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
$ref: "#/components/schemas/ListCapMeta"
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
- meta
|
||||
additionalProperties: false
|
||||
ListCapMeta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
truncated:
|
||||
type: boolean
|
||||
description: "True when more rows exist than this response carries: the list was cut at `limit`, newest or most relevant
|
||||
first as the operation states. False means the list is complete. Show the person that the list is partial
|
||||
rather than letting the last row read as the oldest there is."
|
||||
limit:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 10000
|
||||
description: The most rows this response could carry.
|
||||
required:
|
||||
- truncated
|
||||
- limit
|
||||
additionalProperties: false
|
||||
description: How a capped list was cut. Present on every response of an operation that declares it.
|
||||
CycleInsightsEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
@@ -45167,14 +45212,11 @@ components:
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
$ref: "#/components/schemas/ListCapMeta"
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
- meta
|
||||
additionalProperties: false
|
||||
SecurityActivityResponse:
|
||||
type: object
|
||||
@@ -46250,6 +46292,23 @@ components:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
mainAsleepMinutes:
|
||||
description: The main night's own time asleep in minutes; null on a day of naps alone. On a sleep row `value` is the
|
||||
day's total, the main night plus its naps.
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
- type: "null"
|
||||
napCount:
|
||||
description: Naps on the same wake day.
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
napAsleepMinutes:
|
||||
description: Time asleep across those naps, in minutes.
|
||||
type: number
|
||||
minimum: 0
|
||||
required:
|
||||
- id
|
||||
- type
|
||||
@@ -47074,6 +47133,23 @@ components:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
mainAsleepMinutes:
|
||||
description: The main night's own time asleep in minutes; null on a day of naps alone. On a sleep row `value` is the
|
||||
day's total, the main night plus its naps.
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
- type: "null"
|
||||
napCount:
|
||||
description: Naps on the same wake day.
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
napAsleepMinutes:
|
||||
description: Time asleep across those naps, in minutes.
|
||||
type: number
|
||||
minimum: 0
|
||||
required:
|
||||
- id
|
||||
- type
|
||||
@@ -47255,6 +47331,23 @@ components:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
mainAsleepMinutes:
|
||||
description: The main night's own time asleep in minutes; null on a day of naps alone. On a sleep row `value` is the
|
||||
day's total, the main night plus its naps.
|
||||
anyOf:
|
||||
- type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
- type: "null"
|
||||
napCount:
|
||||
description: Naps on the same wake day.
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
napAsleepMinutes:
|
||||
description: Time asleep across those naps, in minutes.
|
||||
type: number
|
||||
minimum: 0
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
@@ -47890,7 +47983,9 @@ components:
|
||||
- naps
|
||||
additionalProperties: false
|
||||
description: "One reconstructed sleep night: the main session's hypnogram segments + breakdown, plus same-wake-day naps
|
||||
surfaced separately."
|
||||
surfaced separately. A session is a nap when it holds under three hours asleep centred between 10:00 and 20:00
|
||||
local time; the main session is the longest session that is not a nap. A day whose only sleep is naps has `main:
|
||||
null` and its naps."
|
||||
SleepRhythmResponse:
|
||||
type: object
|
||||
properties:
|
||||
@@ -49447,14 +49542,11 @@ components:
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
$ref: "#/components/schemas/ListCapMeta"
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
- meta
|
||||
additionalProperties: false
|
||||
MedicationSideEffect:
|
||||
type: object
|
||||
@@ -55782,6 +55874,7 @@ components:
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
- nl
|
||||
required:
|
||||
- locale
|
||||
additionalProperties: false
|
||||
@@ -62980,14 +63073,30 @@ components:
|
||||
- type: "null"
|
||||
description: Non-null when two writer buckets reported clearly different asleep totals for the latest night's main
|
||||
session. Observational only — the served summary stays the winning writer's totals.
|
||||
recoveryEmpty:
|
||||
anyOf:
|
||||
- type: string
|
||||
enum:
|
||||
- general
|
||||
- oura_permission
|
||||
- oura_no_resilience
|
||||
- type: "null"
|
||||
description: "Why the recovery page has nothing to chart, resolved on the server; null when any recovery type
|
||||
(ANS_CHARGE, DAY_STRAIN, WORKOUT_STRAIN, CARDIO_LOAD, AVERAGE_HEART_RATE, MAX_HEART_RATE,
|
||||
ENERGY_EXPENDITURE_KJ, RESILIENCE) has a reading. `oura_permission`: Oura is connected and its grant leaves
|
||||
out the scope the resilience level needs, one reconnect fixes it. `oura_no_resilience`: Oura is connected
|
||||
and no level has arrived yet. `general`: anyone else. Clients render the empty note from this value and
|
||||
never work it out from the connection status."
|
||||
required:
|
||||
- summaries
|
||||
- bmi
|
||||
- lastSeenByType
|
||||
- sleepSourceDiscrepancy
|
||||
- recoveryEmpty
|
||||
additionalProperties: false
|
||||
description: "What `?slice=summaries` answers: the per-type summaries and their freshness, and nothing thick. Four keys,
|
||||
and the caller gets them from two SQL passes rather than the default slice's chain."
|
||||
description: "What `?slice=summaries` answers: the per-type summaries and their freshness, the recovery page's empty
|
||||
reason, and nothing thick. Five keys, and the caller gets them from two SQL passes rather than the default
|
||||
slice's chain."
|
||||
InsightsGenerateReadEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
@@ -64822,14 +64931,11 @@ components:
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
$ref: "#/components/schemas/ListCapMeta"
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
- meta
|
||||
additionalProperties: false
|
||||
EcgListResponse:
|
||||
type: object
|
||||
@@ -65092,14 +65198,11 @@ components:
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
$ref: "#/components/schemas/ListCapMeta"
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
- meta
|
||||
additionalProperties: false
|
||||
RhythmEventsResponse:
|
||||
type: object
|
||||
@@ -72808,14 +72911,11 @@ components:
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
$ref: "#/components/schemas/ListCapMeta"
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
- meta
|
||||
additionalProperties: false
|
||||
IllnessEpisode:
|
||||
type: object
|
||||
@@ -73495,14 +73595,11 @@ components:
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
$ref: "#/components/schemas/ListCapMeta"
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
- meta
|
||||
additionalProperties: false
|
||||
ListMentalHealthAssessmentsResponse:
|
||||
type: object
|
||||
@@ -73721,14 +73818,11 @@ components:
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
$ref: "#/components/schemas/ListCapMeta"
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
- meta
|
||||
additionalProperties: false
|
||||
Allergy:
|
||||
type: object
|
||||
@@ -73879,14 +73973,11 @@ components:
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
$ref: "#/components/schemas/ListCapMeta"
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
- meta
|
||||
additionalProperties: false
|
||||
FamilyHistoryEntry:
|
||||
type: object
|
||||
@@ -76528,6 +76619,17 @@ components:
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
lastEnteredHereAt:
|
||||
description: "v1.43.7 — present only on a type the app writes back to Apple Health (weight, blood pressure, blood
|
||||
glucose while their entry is `bidirectional` or `writeOnly` and enabled): when the newest reading of the
|
||||
type was entered here rather than arriving from Apple Health, or null when none was. Such readings go to the
|
||||
phone and never come back as an arrival, so `stale` already counts them: the type is quiet only when both
|
||||
this and `lastSeenAt` are. Show it as the server sends it; do not recompute `stale`."
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
required:
|
||||
- type
|
||||
- lastSeenAt
|
||||
@@ -81272,14 +81374,11 @@ components:
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
$ref: "#/components/schemas/ListCapMeta"
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
- meta
|
||||
additionalProperties: false
|
||||
RecordActivity:
|
||||
type: object
|
||||
@@ -81297,9 +81396,9 @@ components:
|
||||
bounded to this window, so it is the real limit of the list and not only a caption. Show it beside the rows.
|
||||
truncated:
|
||||
type: boolean
|
||||
description: True when the read reached its row ceiling, so `entries` is the most recent activity inside the window
|
||||
rather than all of it. A client must say so — a list that stops at its own cap in silence lets the oldest
|
||||
row read as the beginning. False means the list is everything inside the window.
|
||||
deprecated: true
|
||||
description: "Deprecated: read `meta.truncated`, which carries the same value. Whether the list stopped at its row
|
||||
ceiling; kept in `data` for clients built against v1.43.6 and earlier."
|
||||
required:
|
||||
- entries
|
||||
- retentionDays
|
||||
@@ -81363,14 +81462,11 @@ components:
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
$ref: "#/components/schemas/ListCapMeta"
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
- meta
|
||||
additionalProperties: false
|
||||
AccountGrantList:
|
||||
type: object
|
||||
@@ -82785,6 +82881,7 @@ components:
|
||||
- it
|
||||
- pl
|
||||
- ko
|
||||
- nl
|
||||
- type: "null"
|
||||
timezone:
|
||||
type: string
|
||||
@@ -83221,14 +83318,11 @@ components:
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
$ref: "#/components/schemas/ListCapMeta"
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
- meta
|
||||
additionalProperties: false
|
||||
PersonalRecord:
|
||||
type: object
|
||||
@@ -88238,9 +88332,9 @@ components:
|
||||
type: number
|
||||
description: Effective weight after redistributing missing sub-scores.
|
||||
gap:
|
||||
description: "Only on a null `consistency` / `timing`: why it is withheld. `few_nights` — fewer than three nights in the
|
||||
window have a main sleep on the clock (count in `rhythmNights`); `no_main_sleep` — the latest day had
|
||||
only a daytime nap. Absent otherwise."
|
||||
description: "Only on a null `consistency` / `timing`: why it is withheld. `few_nights` — fewer than `rhythmMinNights`
|
||||
nights in the window have a main sleep on the clock (count in `rhythmNights`); `no_main_sleep` — the
|
||||
latest day had only a daytime nap. Absent otherwise."
|
||||
type: string
|
||||
enum:
|
||||
- few_nights
|
||||
@@ -88262,6 +88356,12 @@ components:
|
||||
maximum: 9007199254740991
|
||||
description: Nights in the window with a main sleep placed on the clock — what consistency and timing are computed from.
|
||||
A daytime nap is not one; a short night is.
|
||||
rhythmMinNights:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: 'How many `rhythmNights` consistency and timing need before they are scored; below it they carry `gap:
|
||||
"few_nights"`.'
|
||||
series:
|
||||
type: array
|
||||
items:
|
||||
@@ -88276,6 +88376,7 @@ components:
|
||||
- subScores
|
||||
- windowNights
|
||||
- rhythmNights
|
||||
- rhythmMinNights
|
||||
- series
|
||||
additionalProperties: false
|
||||
parameters:
|
||||
|
||||
@@ -245,8 +245,10 @@ reading:
|
||||
upper-bound filter Google refused.
|
||||
- `stopReason`: why it ended early. `time_budget` and `page_budget` are the
|
||||
normal end of a slice, `rate_limited` means Google would not take more
|
||||
requests for now, `job_budget` that the job itself ran out of time.
|
||||
`null` means the slice read everything it started.
|
||||
requests for now, `job_budget` that the job itself ran out of time,
|
||||
`lane_wanted` that the slice made room for another heavy job (a nightly
|
||||
run, a backup, an upload) that was waiting; the next slice starts two
|
||||
minutes later. `null` means the slice read everything it started.
|
||||
- `collectionsDone` of `collectionsTotal`: data types read to the end so
|
||||
far.
|
||||
- `collections`: one entry per data type the slice touched, with its
|
||||
@@ -265,7 +267,10 @@ reading:
|
||||
nothing left but a data type waiting for its second attempt and queued the
|
||||
next slice for when it is due (`delaySeconds`).
|
||||
- `meta["googleHealth.backfill.ended"]` appears on the slice where a data
|
||||
type ended short, with the same entries. A walk stopped for lack of
|
||||
type ended short: the `collection`, its measurement `type`, the `reason`,
|
||||
whether the end is `final`, how many data types have ended short so far
|
||||
(`endedCount`) and of those for good (`finalCount`), and their `types`.
|
||||
The full entries are in the slice report's `ended`. A walk stopped for lack of
|
||||
progress also leaves a warning:
|
||||
`fetchHeartRate: 20 pages in a row reached no further back than <instant>`.
|
||||
- `meta["googleHealth.backfill.edgeFilterUnsupported"]` appears on the slice
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
# `attemptNumber` audit-row vs alert-signal semantics
|
||||
# `attempt_number` signal vs alert semantics
|
||||
|
||||
Two numbers describe a failing integration sync, and they can diverge.
|
||||
On-call operators reading an audit-log row alongside an admin alert
|
||||
should expect the alert number to be smaller than (or equal to) the
|
||||
audit number, never the other way around.
|
||||
Operators reading an `integrations.sync.failed` log line alongside an admin
|
||||
alert should expect the alert number to be smaller than (or equal to) the
|
||||
logged number, never the other way around.
|
||||
|
||||
Since v1.43.7 a failed sync is a `warn` signal in the log store, not an
|
||||
audit-log row: the audit log records what a person or an admin did to an
|
||||
account, and a provider answering 503 is neither. The provider's message is
|
||||
not on the signal; it stays encrypted on the integration's status row and
|
||||
shows in the integration's settings.
|
||||
|
||||
## The two numbers
|
||||
|
||||
`integrations.sync.failed` audit rows write
|
||||
`attemptNumber = transient + reauth_required + persistent` — the **sum**
|
||||
`integrations.sync.failed` signals carry
|
||||
`attempt_number = transient + reauth_required + persistent` — the **sum**
|
||||
of all three failure buckets. This is the historical "consecutive
|
||||
failures" running total carried by the legacy
|
||||
`IntegrationStatus.consecutiveFailures` column (dropped in migration
|
||||
`0077`, v1.4.47). Auditors looking at a single row see one integer
|
||||
`0077`, v1.4.47). An operator looking at a single line sees one integer
|
||||
that captures how many failures the bucket ledger has accumulated since
|
||||
the last success.
|
||||
|
||||
@@ -27,14 +33,14 @@ single-bucket threshold even if its sum looks alarming.
|
||||
## When the two diverge
|
||||
|
||||
A typed-failure-streak edge case: integration fails 2× transient, then
|
||||
2× reauth_required, then 1× persistent. Audit row shows
|
||||
`attemptNumber: 5`. Alert signal is `max(2, 2, 1) = 2`, which is below
|
||||
the default threshold of 3, so no page fires. From the audit log this
|
||||
2× reauth_required, then 1× persistent. The last signal shows
|
||||
`attempt_number: 5`. The alert number is `max(2, 2, 1) = 2`, which is below
|
||||
the default threshold of 3, so no page fires. From the log this
|
||||
looks like a sustained 5-failure outage; from the alert dispatcher it
|
||||
looks like three small bumps that never crossed a single-bucket
|
||||
ceiling.
|
||||
|
||||
Both readings are correct for their purpose. The audit log records what
|
||||
Both readings are correct for their purpose. The signal records what
|
||||
happened end-to-end; the alert dispatcher only fires when a single
|
||||
failure mode persists, because mixed-bucket streaks usually indicate a
|
||||
transient network blip layered on top of an unrelated reauth prompt —
|
||||
@@ -43,7 +49,7 @@ we want to wake someone up for.
|
||||
|
||||
## Runbook implication
|
||||
|
||||
If you see an audit row with `attemptNumber >= threshold` but no alert
|
||||
If you see a signal with `attempt_number >= threshold` but no alert
|
||||
was dispatched, check the per-bucket counts on
|
||||
`IntegrationStatus.consecutiveFailuresByKind` (DB column
|
||||
`consecutive_failures_by_kind`) for that user/integration pair. The
|
||||
|
||||
@@ -40,6 +40,55 @@ not reachable". The entrypoint also runs the migrations on every boot, so
|
||||
step 2 is optional; it is there for when you want to see the migration
|
||||
result before the new container takes traffic.
|
||||
|
||||
### A migration that timed out on a lock
|
||||
|
||||
Some migrations bound how long they wait for a table lock, so that a
|
||||
long-running holder (most often an autovacuum that is preventing
|
||||
transaction-ID wraparound, which does not step aside) cannot queue every
|
||||
write to that table behind the migration. Migration `0412` waits at most ten
|
||||
seconds for `measurements`. When the wait runs out, the boot log shows
|
||||
Prisma's report followed by a line from the entrypoint:
|
||||
|
||||
```
|
||||
Error: P3018
|
||||
...
|
||||
Migration name: 0412_measurement_hot_updates
|
||||
|
||||
Database error code: 55P03
|
||||
...
|
||||
HealthLog: migration 0412_measurement_hot_updates timed out waiting for a lock (55P03) and changed nothing; marking it rolled back and retrying in 15s (retry 1 of 5).
|
||||
```
|
||||
|
||||
What happens on its own: such a migration is written so that a timed-out
|
||||
attempt changes nothing, and it says so in its file
|
||||
(`-- @retry-on-lock-timeout`). The entrypoint then marks that one migration
|
||||
rolled back (`prisma migrate resolve --rolled-back`) and runs
|
||||
`migrate deploy` again after 15, 30, 60, 120 and 240 seconds. The app starts
|
||||
as soon as an attempt gets the lock. A later boot that finds the attempt still
|
||||
on record (Prisma's `P3009`) reads the cause from `_prisma_migrations` and
|
||||
does the same. Nothing else is retried: any other failure, and a lock timeout
|
||||
in a migration that does not carry the marker, stops the boot exactly as
|
||||
before.
|
||||
|
||||
If all five retries time out, the log ends with
|
||||
`HealthLog: ERROR — migration … timed out on a lock 6 times in a row; giving
|
||||
up.` and the container exits. Something has held the table for about eight
|
||||
minutes. Find it:
|
||||
|
||||
```sh
|
||||
docker compose exec db psql -U healthlog -c "
|
||||
SELECT pid, backend_type, state, now() - xact_start AS running, query
|
||||
FROM pg_stat_activity
|
||||
WHERE pid IN (SELECT pid FROM pg_locks
|
||||
WHERE relation = 'measurements'::regclass)"
|
||||
```
|
||||
|
||||
An `autovacuum: VACUUM … (to prevent wraparound)` row finishes on its own;
|
||||
wait for it to disappear and start the container again (a restart policy does
|
||||
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.11.0 notes
|
||||
|
||||
Migrations `0110`–`0114` are additive (new tables / columns for the
|
||||
|
||||
@@ -7,7 +7,7 @@ A Node process that reaches its heap limit stops with `JavaScript heap out of me
|
||||
Two background wide events, no configuration needed:
|
||||
|
||||
- `runtime.heap.sampled` (info), every five minutes, and every minute while the heap is at or above 70 % of its limit. It goes to stdout and Loki but not to Admin > App logs, so it cannot push other events out of that page's 500-entry buffer. It carries `heap_used_mb`, `heap_total_mb`, `heap_limit_mb`, `heap_used_ratio`, `old_space_mb`, `rss_mb`, `external_mb`, `array_buffers_mb`, `active_requests`, `active_jobs`, `in_flight_dropped` and `uptime_s`.
|
||||
- `runtime.heap.pressure` (warn), the first time the heap reaches 85 % of its limit. Besides the figures above it lists the 50 oldest requests and jobs in flight (`in_flight_oldest`: kind, method, route, age in seconds, whether it was a server action) and the routes most of them share (`in_flight_by_route`). Routes are reduced to their shape: no query string, secret path segments replaced by `[REDACTED]`, ids by `:id`, anything unusual by `:x`. It writes again only after the heap has dropped below 70 % and thirty minutes have passed.
|
||||
- `runtime.heap.pressure` (warn), the first time the heap reaches 85 % of its limit. Besides the figures above it lists the 50 oldest requests and jobs in flight (`in_flight_oldest`: kind, method, route, age in seconds, whether it was a server action) and the routes most of them share (`in_flight_by_route`). Routes are reduced to their shape: no query string, secret path segments replaced by `[REDACTED]`, ids by `:id`, anything unusual by `:x`. It writes again only after the heap has dropped below 70 % and thirty minutes have passed. One exception: when the heap reaches 95 %, the same report is written again at once, with `pressure_ratio: 0.95`, whatever the cooldown; that tier re-arms as soon as the heap is back under 85 %. A heap that climbs to its limit shortly after an 85 % report therefore still leaves a report naming the work that pushed it over.
|
||||
|
||||
The heap is read every five seconds, a single call that takes microseconds. Tracking requests costs one map insert and one delete per request.
|
||||
|
||||
@@ -17,6 +17,16 @@ To find the last lines before a crash:
|
||||
docker compose logs app --since 2h | grep '"runtime.heap.'
|
||||
```
|
||||
|
||||
## The report Node writes when the heap runs out
|
||||
|
||||
The image sets `NODE_OPTIONS="--report-on-fatalerror --report-filename=stderr --report-exclude-env --report-exclude-network"`. When the process runs out of heap, Node writes a diagnostic report, about 10 KB of JSON with `"trigger": "OOMError"`, to stderr just before it exits: the JavaScript stack at the failed allocation, the native stack, the heap spaces and the open handles. It lands in the container log beside the `runtime.heap.*` lines:
|
||||
|
||||
```sh
|
||||
docker compose logs app --since 2h | grep -A400 '"trigger": "OOMError"'
|
||||
```
|
||||
|
||||
The two exclude flags keep environment variables (keys, passwords, the database URL) and network interfaces out of the report. If you set `NODE_OPTIONS` yourself, for example to raise `--max-old-space-size`, your value replaces the image's; add the four flags to it to keep the report.
|
||||
|
||||
## Optional heap snapshot
|
||||
|
||||
A snapshot shows what the heap is made of, which the events above cannot. Turn it on only while you are chasing a specific crash:
|
||||
|
||||
@@ -137,7 +137,8 @@ it.
|
||||
## Reading the result
|
||||
|
||||
The run's wide event `job.measurement_maintenance` carries
|
||||
`maintenance_outcome` (`completed`, `refused_purge_running`, `failed`), the
|
||||
`maintenance_outcome` (`completed`, `nothing_due` for the nightly check,
|
||||
`refused_purge_running`, `failed`), the live and dead rows the run started from, the
|
||||
table and index sizes before and after, and `maintenance_steps`, one entry
|
||||
per step with its target, size before and after, and duration. The same
|
||||
sizes from `psql`:
|
||||
@@ -147,6 +148,43 @@ SELECT pg_size_pretty(pg_total_relation_size('measurements')) AS total,
|
||||
pg_size_pretty(pg_indexes_size('measurements')) AS indexes;
|
||||
```
|
||||
|
||||
## The nightly check
|
||||
|
||||
Since v1.43.7 the same queue also runs by itself at 03:40 (Europe/Berlin).
|
||||
That run names no steps and changes nothing: vacuuming the table is
|
||||
autovacuum's job, which migration 0412 starts at 5 % dead rows instead of
|
||||
Postgres's 20 %. The check reads the table's live and dead rows and warns
|
||||
(`maintenance_autovacuum_behind` on the wide event) when there are at least
|
||||
10,000 dead rows and more than one for every five live ones, four times the
|
||||
point at which autovacuum should already have run. That means autovacuum is
|
||||
not keeping up: look for a transaction left open for hours
|
||||
(`pg_stat_activity`, `state = 'idle in transaction'`) or for autovacuum
|
||||
workers busy elsewhere. The check never rebuilds an index. It shares the
|
||||
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
|
||||
reusable but does not shrink the file, and only `VACUUM FULL` does (see
|
||||
below).
|
||||
|
||||
Why the table grew in the first place: until v1.43.7 an update that changed
|
||||
nothing still moved `updated_at`, which sits in an index, so no update could
|
||||
stay on its page. Migration 0412 keeps `updated_at` and `sync_version` on such
|
||||
an update and gives the table `fillfactor = 85` and a 5 % autovacuum
|
||||
threshold. To check that updates stay on their page:
|
||||
|
||||
```sql
|
||||
SELECT n_tup_upd, n_tup_hot_upd, n_live_tup, n_dead_tup,
|
||||
pg_size_pretty(pg_relation_size('measurements')) AS heap
|
||||
FROM pg_stat_user_tables WHERE relname = 'measurements';
|
||||
```
|
||||
|
||||
`n_tup_hot_upd` should now grow along with `n_tup_upd`. The fill factor
|
||||
applies to pages written after the migration; a `VACUUM FULL` after the
|
||||
upgrade applies it to the whole table.
|
||||
|
||||
## "could not resize shared memory segment"
|
||||
|
||||
Up to v1.42.0 the run could stop at once with `could not resize shared memory
|
||||
@@ -160,6 +198,6 @@ runs single-process and needs no shared segment, and the bundled
|
||||
## Not part of this
|
||||
|
||||
`VACUUM FULL` would shrink the table file as well, but it locks the table for
|
||||
reads and writes while it runs. After the purge the remaining table is small
|
||||
and the space inside it is reused by new rows, so it is not needed. If you
|
||||
want it anyway, run it yourself in a maintenance window with the app stopped.
|
||||
reads and writes while it runs, so no job starts it. When the nightly check
|
||||
warns about the bloat factor, run it yourself in a maintenance window with the
|
||||
app stopped.
|
||||
@@ -79,9 +79,13 @@ Raise the limit on the container that runs the worker and re-upload:
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
NODE_OPTIONS: --max-old-space-size=1024
|
||||
NODE_OPTIONS: --max-old-space-size=1024 --report-on-fatalerror --report-filename=stderr --report-exclude-env --report-exclude-network
|
||||
```
|
||||
|
||||
Your `NODE_OPTIONS` replaces the image's, so carry the four `--report-*`
|
||||
flags over: they make an out-of-memory exit leave a diagnostic report in
|
||||
the container log (see `docs/ops/heap-diagnostics.md`).
|
||||
|
||||
An import interrupted by a container restart or an out-of-memory kill
|
||||
does not stay stuck: the worker reconciles non-terminal import jobs at
|
||||
boot and every 15 minutes, flipping orphaned rows to `failed`
|
||||
|
||||
@@ -132,7 +132,8 @@ helper text explaining why. Silent disabled buttons frustrate users.
|
||||
|
||||
### 2.2 Inputs and forms
|
||||
|
||||
We use `react-hook-form` + Zod schemas from `src/lib/validations/`.
|
||||
Forms keep a controlled draft in component state and validate it with the
|
||||
Zod schemas from `src/lib/validations/`; the server's schema is the authority.
|
||||
Wrap inputs in `<label>` (visible) plus `aria-describedby` for helper text.
|
||||
|
||||
- **Text / number / email**: `<Input>`. Always set `autocomplete`. See §4.4.
|
||||
@@ -425,8 +426,7 @@ control. Behavior:
|
||||
|
||||
### 4.6 Server actions and mutations
|
||||
|
||||
We use `react-hook-form` + TanStack Query mutations, never raw fetch
|
||||
calls in components. Every mutation:
|
||||
Mutations go through TanStack Query, never raw fetch calls in components. Every mutation:
|
||||
|
||||
1. Optimistically updates the cache with a rollback handler.
|
||||
2. Toast on success ("Saved" — translated) and on error (with retry).
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect, test } from "./setup/test";
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* v1.4.15 phase-B4 — /achievements navigation smoke.
|
||||
* v1.4.15 — /achievements navigation smoke.
|
||||
*
|
||||
* The page itself has unit-test coverage for the locked-vs-unlocked
|
||||
* card render; this spec answers the higher-level "can the user reach
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect, test } from "./setup/test";
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* v1.4.16 A3 — `/admin/api-tokens` must not produce horizontal page
|
||||
* v1.4.16 — `/admin/api-tokens` must not produce horizontal page
|
||||
* overflow on a Pixel-5 viewport (393 CSS px). v1.4.15 added column-
|
||||
* hide breakpoints + tighter card padding, but the maintainer still saw a
|
||||
* scrollbar on prod. The v1.4.16 fix swaps the desktop `<table>` for a
|
||||
@@ -75,7 +75,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
|
||||
).toBeLessThanOrEqual(dims.innerWidth + 1);
|
||||
});
|
||||
|
||||
// v1.4.18 phase A2 — production probe at Pixel-5 viewport pinned the
|
||||
// v1.4.18 — production probe at Pixel-5 viewport pinned the
|
||||
// visible "scrollbar" the maintainer reported for the third time as the
|
||||
// `<AdminShell>` mobile section strip, NOT the api-tokens table. The
|
||||
// strip carries 13 admin sections so its scrollWidth (~1700 CSS px)
|
||||
@@ -119,7 +119,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
|
||||
expect(stripDims.scrollWidth).toBeGreaterThan(stripDims.clientWidth);
|
||||
});
|
||||
|
||||
// v1.4.19 phase A7 (4th attempt) — even with the mobile-strip
|
||||
// v1.4.19 (4th attempt) — even with the mobile-strip
|
||||
// `no-scrollbar` fix from v1.4.18, the maintainer still reported a painted
|
||||
// scrollbar. The card body itself must not contribute any
|
||||
// horizontal-overflow culprit: every element inside the api-tokens
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect, test } from "./setup/test";
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* v1.4.16 phase B2 — AI provider settings UX with single dropdown
|
||||
* v1.4.16 — AI provider settings UX with single dropdown
|
||||
* driving the form below. Three things to lock in here:
|
||||
*
|
||||
* 1. /settings/ai exposes the active-provider Pulldown
|
||||
|
||||
@@ -4,7 +4,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { mockDashboardSnapshot } from "./utils/mock-dashboard-snapshot";
|
||||
|
||||
/**
|
||||
* v1.4.19 A2 — mobile-viewport regression spec for chart cards.
|
||||
* v1.4.19 — mobile-viewport regression spec for chart cards.
|
||||
*
|
||||
* Two assertions per chart visible in the dashboard's initial paint:
|
||||
*
|
||||
|
||||
@@ -98,7 +98,7 @@ test.describe("authenticated dashboard render", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
// S2 — the Today hero reads the unified daily digest from
|
||||
// The Today hero reads the unified daily digest from
|
||||
// `/api/daily/digest`. Mock it with a deterministic digest (a score +
|
||||
// one worth-a-look item) so the hero paints without reaching the real
|
||||
// route, and so a slow/absent digest can never hang the render or
|
||||
@@ -276,7 +276,7 @@ test.describe("authenticated dashboard render", () => {
|
||||
const tileCount = Number(await strip.getAttribute("data-tile-count"));
|
||||
expect(tileCount).toBeGreaterThan(0);
|
||||
|
||||
// S2 — the Today hero paints above the tile strip from the mocked
|
||||
// The Today hero paints above the tile strip from the mocked
|
||||
// digest: its score ring and its worth-a-look rail render.
|
||||
const todayHero = page.locator('[data-slot="today-hero"]');
|
||||
await expect(todayHero).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect, test } from "./setup/test";
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* v1.4.28 R3a FB-D3 — `/insights` scroll restoration + tab-strip
|
||||
* v1.4.28 — `/insights` scroll restoration + tab-strip
|
||||
* gesture handling.
|
||||
*
|
||||
* Two regressions the maintainer hit walking through v1.4.27:
|
||||
|
||||
@@ -132,7 +132,7 @@ test.describe("add measurement flow", () => {
|
||||
|
||||
await openMenu(page, addTrigger);
|
||||
|
||||
// v1.5 phase-5: the menu items now have distinct labels — the
|
||||
// v1.5: the menu items now have distinct labels — the
|
||||
// measurement entry says "Measurement" / "Messung" instead of "Add",
|
||||
// so we can target it directly.
|
||||
// The desktop rail opens a dropdown, the phone shell the capture sheet
|
||||
|
||||
@@ -41,7 +41,7 @@ test.describe("mobile-viewport smoke", () => {
|
||||
// mocks below stay for the reversible `=false` path.
|
||||
await mockDashboardSnapshot(page, { summaries: WEIGHT_ONLY_SUMMARIES });
|
||||
|
||||
// v1.4.37 W-CI — match `/api/analytics` AND any sliced variant
|
||||
// v1.4.37 — match `/api/analytics` AND any sliced variant
|
||||
// (`?slice=summaries`). The previous string glob `**/api/analytics`
|
||||
// didn't match the query-string form so the IW1 slim-slice fetch
|
||||
// landed on the unmocked real route in CI.
|
||||
@@ -149,7 +149,7 @@ test.describe("mobile-viewport smoke", () => {
|
||||
// scroll to interact, which is its own UX problem the design
|
||||
// review handles.
|
||||
if (box.y < 0 || box.y > viewportH) continue;
|
||||
// v1.4.37 W-CI — enforce the WCAG 2.5.5 height floor only.
|
||||
// v1.4.37 — enforce the WCAG 2.5.5 height floor only.
|
||||
// The original `width < 44 || height < 44` check flagged
|
||||
// grouped compact controls (chart-range tabs like "7T"/"30T"
|
||||
// /"90T" sitting at 44 px tall but ~30-40 px wide) as
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect, test } from "./setup/test";
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* v1.4.15 phase-A3 fix #2 — mood-list mobile card was rendering the score
|
||||
* v1.4.15 #2 — mood-list mobile card was rendering the score
|
||||
* twice: once in the left badge (large bold digit) and once in the title
|
||||
* line as "{score} ({label})". On desktop the table view only ever showed
|
||||
* one. The fix collapses the title line down to just the localized label
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "./utils/mock-dashboard-snapshot";
|
||||
|
||||
/**
|
||||
* v1.4.15 phase-A3 fix #3 — onboarding card flicker on dashboard load.
|
||||
* v1.4.15 #3 — onboarding card flicker on dashboard load.
|
||||
*
|
||||
* The previous version of `<GettingStartedChecklist>` rendered against a
|
||||
* default-true `shouldShowChecklist` decision while `measurementCount`
|
||||
@@ -64,7 +64,7 @@ test.describe("dashboard onboarding card flicker guard", () => {
|
||||
// works by NOT rendering until this resolves, so even with the
|
||||
// delay the card stays invisible the whole time.
|
||||
//
|
||||
// v1.4.37 W-CI — match `/api/analytics` AND any sliced variant
|
||||
// v1.4.37 — match `/api/analytics` AND any sliced variant
|
||||
// (`?slice=summaries`). The previous string glob `**/api/analytics`
|
||||
// missed the IW1 slim-slice URL so the checklist hook's analytics
|
||||
// query fell through to the real route in CI; on a fresh seed user
|
||||
@@ -161,7 +161,7 @@ test.describe("dashboard onboarding card flicker guard", () => {
|
||||
// card mounts collapsed: header + progress meter visible, full row
|
||||
// list hidden until the user clicks the chevron.
|
||||
//
|
||||
// v1.4.22 C4 — the onboarding redirect now lives in `proxy.ts`,
|
||||
// v1.4.22 — the onboarding redirect now lives in `proxy.ts`,
|
||||
// not in `<AuthShell>`. The proxy reads the `hl_onboarding` cookie
|
||||
// (which the auth routes mirror from the DB). Our storageState
|
||||
// belongs to a fully-onboarded user (no `hl_onboarding` cookie),
|
||||
|
||||
@@ -56,7 +56,7 @@ test.describe("Settings → Export & Import", () => {
|
||||
|
||||
test("renders all export surfaces with stable testids", async ({ page }) => {
|
||||
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
|
||||
// v1.18.0 (S5) — the full health-record export moved to its own
|
||||
// v1.18.0 — the full health-record export moved to its own
|
||||
// `/settings/gesundheitsakte` section. The Export & Import page keeps
|
||||
// the four CSV/backup tiles with the `export-card-*` shape.
|
||||
for (const id of [
|
||||
@@ -72,7 +72,7 @@ test.describe("Settings → Export & Import", () => {
|
||||
test("renders the health-record export panel on its own section", async ({
|
||||
page,
|
||||
}) => {
|
||||
// v1.18.0 (S5) — the health-record export is the hero of the
|
||||
// v1.18.0 — the health-record export is the hero of the
|
||||
// dedicated, module-gated Gesundheitsakte section.
|
||||
await openGesundheitsakte(page);
|
||||
await expect(page.getByTestId("health-record-export-panel")).toBeVisible({
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
WEIGHT_ONLY_SUMMARIES,
|
||||
} from "./utils/mock-dashboard-snapshot";
|
||||
/**
|
||||
* v1.4.19 A6 — Settings consistency snapshot at Pixel-5 (393 CSS px).
|
||||
* v1.4.19 — Settings consistency snapshot at Pixel-5 (393 CSS px).
|
||||
* v1.4.27 — input height floor lifted from 36 px (`h-9`) to 40 px
|
||||
* (`h-10`) on `Input`, `Select` trigger, and the native `<select>`
|
||||
* primitives per the WCAG 2.5.5 tap-target sweep (MB2). The
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* v1.39 (C2) — axe over every setup screen, at both viewports, plus one
|
||||
* v1.39 — axe over every setup screen, at both viewports, plus one
|
||||
* keyboard-only pass through a question.
|
||||
*
|
||||
* The pattern is `a11y.spec.ts`: a scan waits for something the SCREEN
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* v1.39 (C2) — the setup flow, path 1: blood pressure and weight, typed in.
|
||||
* v1.39 — the setup flow, path 1: blood pressure and weight, typed in.
|
||||
*
|
||||
* From the welcome screen to the dashboard on one account of its own: the
|
||||
* questions, leaving after Q3 and coming back to Q4, the units question that
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* v1.39 (C2) — the setup flow, path 4: a child's managed profile.
|
||||
* v1.39 — the setup flow, path 4: a child's managed profile.
|
||||
*
|
||||
* Q1 "someone I look after" keeps the questions in the guardian's own record
|
||||
* and creates the profile on the confirm screen, after the questions — the
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* v1.39 (C2) — driving the setup flow in a browser, shared by its journeys.
|
||||
* v1.39 — driving the setup flow in a browser, shared by its journeys.
|
||||
*
|
||||
* Everything here addresses stable `data-slot` / `data-*` attributes, never
|
||||
* viewport text: the copy is i18n-driven and one of the specs runs every
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* v1.39 (C2) — every setup screen, in every locale, with 30% longer strings.
|
||||
* v1.39 — every setup screen, in every locale, with 30% longer strings.
|
||||
*
|
||||
* The design spec asks for 30–50% string headroom in every layout, and the
|
||||
* seven bundles are the floor, not the ceiling: a German label that fits
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
type SetupScreen,
|
||||
} from "./setup-flow-helpers";
|
||||
|
||||
const LOCALES = ["en", "de", "es", "fr", "it", "pl", "ko"] as const;
|
||||
const LOCALES = ["en", "de", "es", "fr", "it", "pl", "ko", "nl"] as const;
|
||||
|
||||
const VIEWPORTS = [
|
||||
{ label: "desktop 1280", width: 1280, height: 800 },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* v1.39 (C2) — the setup flow, path 2: a daily medication with a reminder.
|
||||
* v1.39 — the setup flow, path 2: a daily medication with a reminder.
|
||||
*
|
||||
* Q3 "yes" is the one answer that makes the first medication the task the
|
||||
* flow ends on, ahead of a reading; every other question is passed, which is
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* v1.39 (C2) — the setup flow, path 3: a doctor's visit within a month, with
|
||||
* v1.39 — the setup flow, path 3: a doctor's visit within a month, with
|
||||
* lab results.
|
||||
*
|
||||
* The visit answer is what switches the doctor report on and puts "prepare
|
||||
|
||||
@@ -585,7 +585,7 @@ export const SEAM_BANNERS_STORAGE_STATE_PATH = resolve(
|
||||
);
|
||||
|
||||
/**
|
||||
* v1.39 (C2) — the setup flow's own accounts, one per journey.
|
||||
* v1.39 — the setup flow's own accounts, one per journey.
|
||||
*
|
||||
* Each journey walks the questions from the welcome screen to the dashboard
|
||||
* and asserts what the walk wrote: the module map, the tile order, the
|
||||
@@ -1550,7 +1550,7 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
// can reuse them via `test.use({ storageState })`. This is the documented
|
||||
// Playwright pattern (`docs/auth.md`) and the only way to avoid the
|
||||
// per-spec login + rate-limit dance.
|
||||
// v1.39 (C2) — the setup flow's accounts. `onboarding_completed_at` NULL
|
||||
// v1.39 — the setup flow's accounts. `onboarding_completed_at` NULL
|
||||
// is the whole point: it is what lands them on `/onboarding`. The tour
|
||||
// flag stays true for the reason every other fixture sets it. Whatever a
|
||||
// previous run wrote — the answers row, the module map, the seeded
|
||||
@@ -1693,7 +1693,7 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
`UPDATE users SET totp_confirmed_at = $2 WHERE username = $1`,
|
||||
[E2E_GUARDIAN.username, now],
|
||||
);
|
||||
// v1.39 (C2) — the setup flow's jars, one per journey and one per sweep.
|
||||
// v1.39 — the setup flow's jars, one per journey and one per sweep.
|
||||
// The child journey's factor is stamped after its capture, for the reason
|
||||
// the guardian's is: the password-only capture cannot pass a TOTP login.
|
||||
await capture(E2E_SETUP_BP, SETUP_BP_STORAGE_STATE_PATH);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* v1.39 (C2) — the state the setup-flow journeys start from, and nothing else.
|
||||
* v1.39 — the state the setup-flow journeys start from, and nothing else.
|
||||
*
|
||||
* Each journey owns one account and walks it from the welcome screen to the
|
||||
* dashboard. What a walk leaves behind — the answers row, the completion
|
||||
|
||||
@@ -199,7 +199,7 @@ export const CONTENT_DOC_ID = "e2evaultcontent00000001";
|
||||
export const CONTENT_BODY_WORD = "pneumothorax";
|
||||
|
||||
/**
|
||||
* v1.28 (Phase 3) — a fixed trio the clinician-share e2e attaches to a link and
|
||||
* v1.28 — a fixed trio the clinician-share e2e attaches to a link and
|
||||
* serves through the public `/c/<token>/d/<id>` route:
|
||||
* - a PDF (Class A, inline preview via <iframe>),
|
||||
* - a JPEG carrying an Exif GPS marker (Class A image, inline <img>; the
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect, test } from "./setup/test";
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* v1.4.16 A1 — Sidebar Admin entry must mirror the Settings entry
|
||||
* v1.4.16 — Sidebar Admin entry must mirror the Settings entry
|
||||
* exactly: a single link with no sub-item expansion in the global
|
||||
* sidebar. The maintainer reported in v1.4.15 that the sidebar was auto-expanding
|
||||
* admin sub-items on `/admin/*` and the gravatar dropdown felt linked to
|
||||
|
||||
@@ -396,7 +396,7 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
|
||||
const held = deferred();
|
||||
// The commit is observable: flag once the server round-trip inside the
|
||||
// route handler finishes, and poll for it instead of a fixed sleep
|
||||
// (C3) — the tab must close AFTER the write landed, and this waits
|
||||
// The tab must close AFTER the write landed, and this waits
|
||||
// exactly that long.
|
||||
let switchCommitted = false;
|
||||
await initiator.route("**/api/account/switch", async (route) => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect, test } from "./setup/test";
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* v1.4.27 B3 — admin login-overview carrier chip + CSV column.
|
||||
* v1.4.27 — admin login-overview carrier chip + CSV column.
|
||||
*
|
||||
* Two contracts under test:
|
||||
*
|
||||
|
||||
@@ -11,7 +11,7 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
* 1. The floating Coach FAB (`data-slot="coach-fab"`) is the single
|
||||
* Coach launcher on every authenticated page. The per-metric inline
|
||||
* launch icon the sub-page header used to mount was retired in
|
||||
* CCH-04 — the FAB is now the only entry. An unread Coach-initiated
|
||||
* The FAB is now the only entry. An unread Coach-initiated
|
||||
* nudge (`GET /api/insights/coach/nudge-status`) paints an unread
|
||||
* dot (`data-slot="coach-fab-unread"`) on its corner; with nothing
|
||||
* unread the FAB renders without the dot.
|
||||
|
||||
@@ -4,7 +4,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
|
||||
/**
|
||||
* v1.4.27 F17 — insights metric-availability gating.
|
||||
* v1.4.27 — insights metric-availability gating.
|
||||
*
|
||||
* When a metric has zero observations the routed sub-page early-
|
||||
* returns an empty-state component with TWO mandatory affordances:
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect, test } from "./setup/test";
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* v1.4.27 MB6 — `/measurements?add=<TYPE>` consumer.
|
||||
* v1.4.27 — `/measurements?add=<TYPE>` consumer.
|
||||
*
|
||||
* Insight metric actions link here with `?add=<TYPE>` and an optional,
|
||||
* allowlisted `returnTo`. The page opens the responsive capture primitive,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect, test } from "./setup/test";
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* v1.4.27 MB6 — public surfaces.
|
||||
* v1.4.27 — public surfaces.
|
||||
*
|
||||
* Three contracts under test, all reachable without a session:
|
||||
*
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect, test } from "./setup/test";
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* v1.4.27 R3d MB1 — `<ResponsiveSheet>` primitive branch coverage.
|
||||
* v1.4.27 — `<ResponsiveSheet>` primitive branch coverage.
|
||||
*
|
||||
* The primitive renders as a bottom-anchored `<Sheet>` on `<md` (768
|
||||
* px) viewports and as a centred `<Dialog>` on `>=md`. Every primary
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Unit tests for the `healthlog/no-planning-tags` rule: a planning-note
|
||||
* identifier at the head of a comment line is reported and autofixed; a
|
||||
* release version, an issue reference, domain vocabulary, ordinary prose and
|
||||
* string contents are left alone.
|
||||
*/
|
||||
import { describe, it } from "vitest";
|
||||
import { RuleTester } from "eslint";
|
||||
import rule from "../no-planning-tags.js";
|
||||
|
||||
RuleTester.describe = describe;
|
||||
RuleTester.it = it;
|
||||
RuleTester.itOnly = it.only;
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
ecmaVersion: 2023,
|
||||
sourceType: "module",
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const error = { messageId: "planningTag" };
|
||||
|
||||
ruleTester.run("no-planning-tags", rule, {
|
||||
valid: [
|
||||
// A release version that dates the behaviour may stay.
|
||||
"// v1.4.27 — `/about` carries the attribution.\nconst x = 1;",
|
||||
"// Since v1.39 every AI decision is one resolution.\nconst x = 1;",
|
||||
// Issue references stay, with or without a version.
|
||||
"// v1.43 (#1234) — a later start inside the same period.\nconst x = 1;",
|
||||
"// v1.43 (iOS #116 item 9) — the age band.\nconst x = 1;",
|
||||
"// Issue #281 — the import surface.\nconst x = 1;",
|
||||
// Domain vocabulary that looks like a code.
|
||||
"// B12 — cobalamin, reported in pmol/L.\nconst x = 1;",
|
||||
"// PM2.5 — fine particulate matter.\nconst x = 1;",
|
||||
"// UTF-8 — the wire encoding.\nconst x = 1;",
|
||||
"// APP13 — Photoshop / IPTC-IIM\nconst x = 1;",
|
||||
// Enumerations and prose.
|
||||
"// Step 2 — parse the payload.\nconst x = 1;",
|
||||
"// Round 2 — the model answers with the tool result.\nconst x = 1;",
|
||||
"// OpenAPI route table — one entry per path.\nconst x = 1;",
|
||||
"/** 2026-05-12 — only one source reported. */\nconst x = 1;",
|
||||
// A label the file's code also uses (a test named `T1 …`).
|
||||
'// T1 — no file hand-rolls the locale.\nit("T1 no file hand-rolls the locale", () => {});',
|
||||
// A tag inside a string is not a comment.
|
||||
'const title = "v1.4.20 B3 — /insights mounts TrendsRow";',
|
||||
// Mid-sentence references are out of the rule's reach by design.
|
||||
"// The card replaced the one from W4 — see the layout.\nconst x = 1;",
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: "// v1.4.34 IW-B — the provider now mounts here.\nconst x = 1;",
|
||||
output: "// v1.4.34 — the provider now mounts here.\nconst x = 1;",
|
||||
errors: [error],
|
||||
},
|
||||
{
|
||||
code: "/**\n * v1.4.40 W-PRIVACY (SB-3) — bilingual public privacy policy.\n */\nconst x = 1;",
|
||||
output:
|
||||
"/**\n * v1.4.40 — bilingual public privacy policy.\n */\nconst x = 1;",
|
||||
errors: [error],
|
||||
},
|
||||
{
|
||||
// The issue reference survives the fix.
|
||||
code: "// v1.30.1 W9 (#812) — pull-to-refresh parity.\nconst x = 1;",
|
||||
output: "// v1.30.1 (#812) — pull-to-refresh parity.\nconst x = 1;",
|
||||
errors: [error],
|
||||
},
|
||||
{
|
||||
// A colon separator after a version.
|
||||
code: "// v1.4.15 phase-C5: upgrade to the EmptyState primitive.\nconst x = 1;",
|
||||
output: "// v1.4.15: upgrade to the EmptyState primitive.\nconst x = 1;",
|
||||
errors: [error],
|
||||
},
|
||||
{
|
||||
// Without a version the tag goes and the sentence starts upper-case.
|
||||
code: "// C4 — the replaced fields; the note is never quoted.\nconst x = 1;",
|
||||
output: "// The replaced fields; the note is never quoted.\nconst x = 1;",
|
||||
errors: [error],
|
||||
},
|
||||
{
|
||||
code: "/** RECON1 (D5-5) — the snapshot driver block. */\nconst x = 1;",
|
||||
output: "/** The snapshot driver block. */\nconst x = 1;",
|
||||
errors: [error],
|
||||
},
|
||||
{
|
||||
code: "// Phase 4 — MCP prompts.\nconst x = 1;",
|
||||
output: "// MCP prompts.\nconst x = 1;",
|
||||
errors: [error],
|
||||
},
|
||||
{
|
||||
code: "// 2026-07-17 UX/IA audit (M6) — which sidebar group this renders under.\nconst x = 1;",
|
||||
output: "// Which sidebar group this renders under.\nconst x = 1;",
|
||||
errors: [error],
|
||||
},
|
||||
{
|
||||
// JSX comment.
|
||||
code: "const el = <div>{/* v1.4.33 IW9 — aria-label for the bar. */}</div>;",
|
||||
output:
|
||||
"const el = <div>{/* v1.4.33 — aria-label for the bar. */}</div>;",
|
||||
errors: [error],
|
||||
},
|
||||
{
|
||||
// Two tagged lines in one block, each reported.
|
||||
code: "/**\n * v1.4.25 W4 — the routed sub-page.\n * v1.4.27 F17 — gated on one observation.\n */\nconst x = 1;",
|
||||
output:
|
||||
"/**\n * v1.4.25 — the routed sub-page.\n * v1.4.27 — gated on one observation.\n */\nconst x = 1;",
|
||||
errors: [error, error],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -14,6 +14,8 @@
|
||||
* src/lib/tz/format, never written out.
|
||||
* - `no-utc-day-key` — a day is cut through the tz module, not by
|
||||
* slicing an ISO string or parsing UTC midnight.
|
||||
* - `no-planning-tags` — no planning-note identifier at the head of a
|
||||
* comment line.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
@@ -26,6 +28,7 @@ const spacingScale = require("./spacing-scale.js");
|
||||
const jobHandlerOutcome = require("./job-handler-outcome.js");
|
||||
const noDefaultZoneLiteral = require("./no-default-zone-literal.js");
|
||||
const noUtcDayKey = require("./no-utc-day-key.js");
|
||||
const noPlanningTags = require("./no-planning-tags.js");
|
||||
|
||||
module.exports = {
|
||||
rules: {
|
||||
@@ -42,5 +45,6 @@ module.exports = {
|
||||
"job-handler-outcome": jobHandlerOutcome,
|
||||
"no-default-zone-literal": noDefaultZoneLiteral,
|
||||
"no-utc-day-key": noUtcDayKey,
|
||||
"no-planning-tags": noPlanningTags,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* @fileoverview ESLint rule — no planning tag at the head of a comment.
|
||||
*
|
||||
* Comments used to open with the identifier of the work item that produced
|
||||
* them. The identifiers point into planning notes that never ship, so a
|
||||
* reader meets something that looks like a reference and leads nowhere.
|
||||
* `scripts/strip-planning-tags.mjs` removed the existing ones; this rule
|
||||
* keeps new ones out. A release version that dates the behaviour and an
|
||||
* issue reference (`#1234`) are allowed and survive the autofix.
|
||||
*
|
||||
* The shape is defined once, in `./planning-tags.js`, which the sweep script
|
||||
* imports as well — see its header for the grammar and for what it does not
|
||||
* catch.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
const { matchPlanningTag, commentLines } = require("./planning-tags.js");
|
||||
|
||||
function escapeRegExp(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/** @type {import("eslint").Rule.RuleModule} */
|
||||
const noPlanningTagsRule = {
|
||||
meta: {
|
||||
type: "suggestion",
|
||||
fixable: "code",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow planning-note identifiers at the head of a comment line.",
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
planningTag:
|
||||
"Planning tag {{tags}} at the head of a comment. Say what the code does; an issue reference (#123) or a release version may stay.",
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
||||
return {
|
||||
Program() {
|
||||
const comments = sourceCode.getAllComments();
|
||||
if (comments.length === 0) return;
|
||||
|
||||
const text = sourceCode.getText();
|
||||
let codeOnly = null;
|
||||
const usedInCode = (tag) => {
|
||||
if (codeOnly === null) {
|
||||
let out = "";
|
||||
let cursor = 0;
|
||||
for (const c of comments) {
|
||||
out += text.slice(cursor, c.range[0]);
|
||||
out += " ".repeat(c.range[1] - c.range[0]);
|
||||
cursor = c.range[1];
|
||||
}
|
||||
codeOnly = out + text.slice(cursor);
|
||||
}
|
||||
return new RegExp(
|
||||
`(?<![A-Za-z0-9-])${escapeRegExp(tag)}(?![A-Za-z0-9-])`,
|
||||
).test(codeOnly);
|
||||
};
|
||||
|
||||
for (const comment of comments) {
|
||||
const raw = text.slice(comment.range[0], comment.range[1]);
|
||||
for (const line of commentLines(raw)) {
|
||||
const hit = matchPlanningTag(line.text, usedInCode);
|
||||
if (!hit) continue;
|
||||
const start = comment.range[0] + line.offset + line.prefix.length;
|
||||
const end = start + line.text.length;
|
||||
context.report({
|
||||
loc: {
|
||||
start: sourceCode.getLocFromIndex(start),
|
||||
end: sourceCode.getLocFromIndex(end),
|
||||
},
|
||||
messageId: "planningTag",
|
||||
data: { tags: hit.tags.join(", ") || "(planning word)" },
|
||||
fix: (fixer) =>
|
||||
fixer.replaceTextRange([start, end], hit.replacement),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = noPlanningTagsRule;
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* @fileoverview Recognises a planning tag at the head of a comment line.
|
||||
*
|
||||
* Comments used to open with the identifier of the work item that produced
|
||||
* them: `v1.4.34 IW-B — …`, `v1.4.25 W4 — …`, `v1.4.40 W-PRIVACY (SB-3) — …`,
|
||||
* `RECON1 (D5-5) — …`, `Phase 4 — …`. Those identifiers point into planning
|
||||
* notes that never ship with the repository, so to a reader they are noise
|
||||
* that looks like a reference. A release version that dates the behaviour may
|
||||
* stay, and so may an issue reference (`#1234`, `iOS #12`); the tag goes.
|
||||
*
|
||||
* One matcher serves two callers: the `healthlog/no-planning-tags` rule, which
|
||||
* reports a tag in a new comment, and `scripts/strip-planning-tags.mjs`, which
|
||||
* removed the existing ones. Sharing it keeps "what the rule flags" and "what
|
||||
* the sweep removed" the same set by construction.
|
||||
*
|
||||
* Shape recognised (on one comment line, after the comment marker):
|
||||
*
|
||||
* [VERSION [/ VERSION …]] HEAD SEP REST
|
||||
*
|
||||
* VERSION `v1.4.34`, `v1.39`
|
||||
* HEAD tokens, every one of them a tag, a planning word, an issue
|
||||
* reference, a separator, or a parenthesised group of those; a
|
||||
* parenthesised group of anything else (`(org-audit rec #2)`) is
|
||||
* kept verbatim. At least one tag or planning word must be present.
|
||||
* SEP an em dash; with a leading VERSION also a colon.
|
||||
*
|
||||
* A line without a version is held to more: the head must carry a tag or a
|
||||
* strong planning word, no tag may be on the domain list below (`B12`,
|
||||
* `PM2.5`, `UTF-8`, …), and no tag may also occur in the file's code — a
|
||||
* test that names its cases `T1`, `T2` keeps its comment labels.
|
||||
*
|
||||
* WHAT THIS DOES NOT CATCH
|
||||
*
|
||||
* A tag in the middle of a sentence (`see W4`), a tag inside a string (test
|
||||
* titles), and an all-capitals code without a digit (`FDREXTEND`). It reads
|
||||
* the head of a line, where the convention put them.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
const VERSION_RE = /^v\d+(?:\.\d+){1,3}$/;
|
||||
const VERSION_PREFIX_RE =
|
||||
/^(v\d+(?:\.\d+){1,3}(?:\s*[/,+&]\s*v\d+(?:\.\d+){1,3})*)\s+/;
|
||||
|
||||
// A work-item code: letters then digits (B3, MB6, W9b, RECON1, D5-5,
|
||||
// W9-CHART-GATE, Code-H9), a word-dash-number code (HIGH-9, SB-SCHED-9,
|
||||
// MOD-12), or the two letter-only families the convention used (IW-B,
|
||||
// IW-F-Perf, W-PRIVACY).
|
||||
const TAG_RES = [
|
||||
/^(?:[A-Z][A-Za-z]*-)*[A-Z]{1,8}\d+[a-z]{0,2}(?:-[A-Za-z0-9]+)*$/,
|
||||
/^[A-Z][A-Za-z]*(?:-[A-Z][A-Za-z]*)*-\d+[a-z]?$/,
|
||||
/^IW(?:-[A-Za-z0-9]+)*$/,
|
||||
/^W-[A-Z][A-Za-z0-9-]*$/,
|
||||
];
|
||||
|
||||
// Codes that are domain or protocol vocabulary, not work items. Only
|
||||
// consulted for a line without a version, where such a word can open an
|
||||
// ordinary comment (`// B12 — cobalamin`, `// UTF-8 — the wire encoding`).
|
||||
const DOMAIN_RE =
|
||||
/^(?:B\d{1,2}|D[23]|K[12]|E[1-4]|T[34]|O[23]|NO2|CO2|SO2|PM\d+(?:\.\d+)?|R2|S3|H[1-6]|A[0-9]|P\d{2,3}|Q[1-4]|V8|ES\d+|SHA-?\d+|MD5|UTF-?\d+|HS\d+|RS\d+|ISO-?\d+|AES-?\d+|RFC-?\d+|COVID-\d+|SARS-CoV-\d+|HTTP-?\d+|X\d+|IPV\d|CSS\d|HTML\d|ECMA-?\d+|WCAG-?\d+|HbA1c|A1C|LDL\d*|HDL\d*|EKG\d*|ECG\d*|PHQ-\d+|GAD-\d+|WHO-\d+|AUDIT-C|ICD-\d+|FHIR-R\d|R4|R5|APP\d+)$/;
|
||||
|
||||
const PLAN_WORDS = new Set([
|
||||
"phase",
|
||||
"wave",
|
||||
"welle",
|
||||
"workstream",
|
||||
"epic",
|
||||
"reconcile",
|
||||
"hotfix",
|
||||
"follow-up",
|
||||
"qa",
|
||||
"qol",
|
||||
"audit",
|
||||
"ux",
|
||||
"ia",
|
||||
"a11y",
|
||||
"perf",
|
||||
"fix",
|
||||
"item",
|
||||
]);
|
||||
// Words that mark a planning tag on their own, without a code beside them.
|
||||
const STRONG_PLAN_WORDS = new Set([
|
||||
"phase",
|
||||
"wave",
|
||||
"welle",
|
||||
"workstream",
|
||||
"epic",
|
||||
"reconcile",
|
||||
]);
|
||||
const PLAN_WORD_SHAPES = [
|
||||
/^phase-[A-Z]?\d*[a-z]?$/i,
|
||||
/^fix-[A-Z0-9]+$/i,
|
||||
/^maintainer-item-\d+$/,
|
||||
];
|
||||
|
||||
const TICKET_WORDS = new Set(["ios", "issue", "issues"]);
|
||||
|
||||
function isTag(token) {
|
||||
return TAG_RES.some((re) => re.test(token));
|
||||
}
|
||||
|
||||
function isPlanWord(token) {
|
||||
return (
|
||||
PLAN_WORDS.has(token.toLowerCase()) ||
|
||||
PLAN_WORD_SHAPES.some((re) => re.test(token))
|
||||
);
|
||||
}
|
||||
|
||||
function isTicket(token) {
|
||||
return (
|
||||
/^#\d+$/.test(token) ||
|
||||
/^(?:ios|iOS)#\d+$/.test(token) ||
|
||||
TICKET_WORDS.has(token.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
const SEPARATORS = new Set(["/", ",", "+", "&"]);
|
||||
|
||||
/**
|
||||
* Split a head into tokens with their offsets. Parentheses and the
|
||||
* separators are their own tokens.
|
||||
*/
|
||||
function tokenize(head) {
|
||||
const out = [];
|
||||
const re = /[()]|[/,+&]|[^\s()/,+&]+/g;
|
||||
let m;
|
||||
while ((m = re.exec(head)) !== null) {
|
||||
out.push({ text: m[0], start: m.index, end: m.index + m[0].length });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify the tokens of one head (or one parenthesised group). Returns
|
||||
* null when a token is foreign; otherwise the per-token verdicts.
|
||||
*/
|
||||
function classify(tokens) {
|
||||
const kinds = tokens.map((t) => {
|
||||
if (SEPARATORS.has(t.text)) return "sep";
|
||||
if (VERSION_RE.test(t.text)) return "version";
|
||||
if (isTicket(t.text)) return "ticket";
|
||||
if (/^\d+$/.test(t.text) && t.text.length <= 3) return "digits";
|
||||
if (isTag(t.text)) return "tag";
|
||||
if (isPlanWord(t.text)) return "plan";
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(t.text)) return "date";
|
||||
if (/^[A-Za-z]$/.test(t.text)) return "letter";
|
||||
return null;
|
||||
});
|
||||
if (kinds.includes(null)) return null;
|
||||
const hasTag = kinds.includes("tag");
|
||||
const hasPlan = kinds.includes("plan");
|
||||
// Bare digits, single letters and dates only belong to a planning head
|
||||
// (`Phase 4`, `Welle J`, `W9b/c`, `2026-07-17 audit M5`).
|
||||
if (!hasTag && !hasPlan) {
|
||||
if (kinds.some((k) => k === "digits" || k === "letter" || k === "date")) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// `item 3` after an issue reference is part of it (`iOS #12 item 3`).
|
||||
for (let i = 1; i < kinds.length; i++) {
|
||||
const isItem = tokens[i].text.toLowerCase() === "item";
|
||||
if (kinds[i - 1] === "ticket" && (isItem || kinds[i] === "digits")) {
|
||||
kinds[i] = "ticket";
|
||||
}
|
||||
}
|
||||
return kinds;
|
||||
}
|
||||
|
||||
function tidy(s) {
|
||||
let prev;
|
||||
let out = s;
|
||||
do {
|
||||
prev = out;
|
||||
out = out
|
||||
.replace(/\(\s*[/,+&]?\s*\)/g, "")
|
||||
.replace(/\(\s*[/,+&]\s*/g, "(")
|
||||
.replace(/\s*[/,+&]\s*\)/g, ")")
|
||||
.replace(/([/,+&])(\s*[/,+&])+/g, "$1")
|
||||
.replace(/^\s*[/,+&]\s*/, "")
|
||||
.replace(/\s*[/,+&]\s*$/, "")
|
||||
.replace(/\(\s+/g, "(")
|
||||
.replace(/\s+\)/g, ")")
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.trim();
|
||||
} while (out !== prev);
|
||||
return out;
|
||||
}
|
||||
|
||||
function capitaliseFirstWord(rest) {
|
||||
const m = rest.match(/^([a-z]+)(?=[\s,.;:-]|$)/);
|
||||
if (!m) return rest;
|
||||
return m[1][0].toUpperCase() + rest.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect the text of one comment line (marker already removed).
|
||||
*
|
||||
* @param {string} text the line after `//`, `/*`, or ` * `.
|
||||
* @param {(tag: string) => boolean} [usedInCode] whether a tag also occurs
|
||||
* in the file's code; only consulted for a line without a version.
|
||||
* @returns {null | { tags: string[], replacement: string }}
|
||||
*/
|
||||
function matchPlanningTag(text, usedInCode = () => false) {
|
||||
const verMatch = text.match(VERSION_PREFIX_RE);
|
||||
const version = verMatch ? verMatch[1] : "";
|
||||
const afterVersion = verMatch ? text.slice(verMatch[0].length) : text;
|
||||
|
||||
const sepRe = version
|
||||
? /^([^—:`"']{1,60}?)\s*(—|:)(\s|$)/
|
||||
: /^([^—:`"']{1,60}?)\s*(—)(\s|$)/;
|
||||
const sm = afterVersion.match(sepRe);
|
||||
if (!sm) return null;
|
||||
const head = sm[1];
|
||||
const sep = sm[2];
|
||||
const rest = afterVersion.slice(sm[0].length);
|
||||
|
||||
const tokens = tokenize(head);
|
||||
if (tokens.length === 0) return null;
|
||||
|
||||
// Split into bare tokens and parenthesised groups (one level).
|
||||
const removable = new Array(tokens.length).fill(false);
|
||||
const tags = [];
|
||||
let sawTagOrPlan = false;
|
||||
let sawStrong = false;
|
||||
const bare = [];
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
if (tokens[i].text === "(") {
|
||||
let j = i + 1;
|
||||
while (j < tokens.length && tokens[j].text !== ")") j++;
|
||||
if (j >= tokens.length) return null;
|
||||
const inner = tokens.slice(i + 1, j);
|
||||
const kinds = classify(inner);
|
||||
if (kinds) {
|
||||
kinds.forEach((k, n) => {
|
||||
if (k === "tag" || k === "plan" || k === "date" || k === "letter") {
|
||||
removable[i + 1 + n] = true;
|
||||
}
|
||||
if (k === "digits" && kinds.includes("plan")) {
|
||||
removable[i + 1 + n] = true;
|
||||
}
|
||||
if (k === "tag") tags.push(inner[n].text);
|
||||
if (k === "tag" || k === "plan") sawTagOrPlan = true;
|
||||
if (
|
||||
k === "plan" &&
|
||||
STRONG_PLAN_WORDS.has(inner[n].text.toLowerCase())
|
||||
)
|
||||
sawStrong = true;
|
||||
});
|
||||
}
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
if (tokens[i].text === ")") return null;
|
||||
bare.push(i);
|
||||
}
|
||||
const bareKinds = classify(bare.map((i) => tokens[i]));
|
||||
if (bare.length > 0 && !bareKinds) return null;
|
||||
if (bareKinds) {
|
||||
bareKinds.forEach((k, n) => {
|
||||
const i = bare[n];
|
||||
if (k === "tag" || k === "plan" || k === "date" || k === "letter") {
|
||||
removable[i] = true;
|
||||
}
|
||||
if (k === "digits" && bareKinds.includes("plan")) removable[i] = true;
|
||||
if (k === "tag") tags.push(tokens[i].text);
|
||||
if (k === "tag" || k === "plan") sawTagOrPlan = true;
|
||||
if (k === "plan" && STRONG_PLAN_WORDS.has(tokens[i].text.toLowerCase()))
|
||||
sawStrong = true;
|
||||
});
|
||||
}
|
||||
if (!sawTagOrPlan) return null;
|
||||
|
||||
if (!version) {
|
||||
if (tags.length === 0 && !sawStrong) return null;
|
||||
if (tags.some((t) => DOMAIN_RE.test(t))) return null;
|
||||
if (tags.some((t) => usedInCode(t))) return null;
|
||||
if (rest.trim() === "") return null;
|
||||
}
|
||||
|
||||
let kept = "";
|
||||
let cursor = 0;
|
||||
tokens.forEach((t, i) => {
|
||||
kept += head.slice(cursor, t.start);
|
||||
if (!removable[i]) kept += t.text;
|
||||
cursor = t.end;
|
||||
});
|
||||
kept += head.slice(cursor);
|
||||
kept = tidy(kept);
|
||||
|
||||
const lead = [version, kept].filter(Boolean).join(" ");
|
||||
const replacement = lead
|
||||
? `${lead}${sep === ":" ? "" : " "}${sep}${rest ? " " + rest : ""}`
|
||||
: capitaliseFirstWord(rest);
|
||||
if (replacement === text) return null;
|
||||
return { tags, replacement };
|
||||
}
|
||||
|
||||
/**
|
||||
* Split one comment's source text into lines with the marker separated, so
|
||||
* a caller can run `matchPlanningTag` on each line and splice the result
|
||||
* back. `raw` is the full comment including its `//` or `/* … *\/`.
|
||||
*/
|
||||
function commentLines(raw) {
|
||||
const lines = raw.split("\n");
|
||||
let offset = 0;
|
||||
return lines.map((line, index) => {
|
||||
const m = line.match(/^(\s*(?:\{?\/\/+|\{?\/\*+|\*+(?!\/))?[ \t]*)(.*)$/);
|
||||
const entry = {
|
||||
index,
|
||||
offset,
|
||||
prefix: m[1],
|
||||
text: m[2],
|
||||
};
|
||||
offset += line.length + 1;
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { matchPlanningTag, commentLines };
|
||||
@@ -115,6 +115,12 @@ const eslintConfig = defineConfig([
|
||||
// carries `eslint-disable-next-line ... -- <reason>`; the rule refuses
|
||||
// a directive without one. See the rule header.
|
||||
"healthlog/no-utc-day-key": "error",
|
||||
// A comment does not open with the identifier of the work item that
|
||||
// produced it: those point into notes that never ship. A release
|
||||
// version and an issue reference may stay. The matcher is shared with
|
||||
// scripts/strip-planning-tags.mjs; see eslint-plugins/healthlog/
|
||||
// planning-tags.js for the shape and its limits.
|
||||
"healthlog/no-planning-tags": "error",
|
||||
},
|
||||
},
|
||||
// v1.28.17 — every recharts-rendering component funnels through the
|
||||
|
||||
@@ -134,11 +134,7 @@
|
||||
"@types/node",
|
||||
"@types/react",
|
||||
"@types/react-dom",
|
||||
"@hookform/resolvers",
|
||||
"react-hook-form",
|
||||
"@prisma/client",
|
||||
"next-themes",
|
||||
"@vitejs/plugin-react",
|
||||
"shadcn",
|
||||
"tailwindcss",
|
||||
"testcontainers",
|
||||
|
||||
+16
-11
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"common": {
|
||||
"listCapped": "Hier stehen die letzten {count}; ältere Einträge sind nicht aufgeführt.",
|
||||
"save": "Speichern",
|
||||
"conflictReloaded": "Vom Server neu geladen. Bitte nimm deine Änderung noch einmal vor.",
|
||||
"cancel": "Abbrechen",
|
||||
@@ -55,7 +56,9 @@
|
||||
"errorIdLabel": "Fehler-ID",
|
||||
"errorIdCopy": "Fehler-ID kopieren",
|
||||
"errorIdCopied": "Fehler-ID kopiert",
|
||||
"readThrottled": "Gerade zu viele Anfragen. Versuch es später noch einmal."
|
||||
"readThrottled": "Gerade gibt es zu viele Anfragen, versuch es später noch einmal.",
|
||||
"sessionEnded": "Deine Sitzung ist abgelaufen, melde dich erneut an, um weiterzumachen.",
|
||||
"signIn": "Anmelden"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
@@ -843,7 +846,7 @@
|
||||
"nextPage": "Nächste Seite",
|
||||
"dailyTotalCaption": "({count} Einzelwerte)",
|
||||
"openMetricInsights": "{type}-Auswertung öffnen",
|
||||
"sleepNapCaption": "+{count} Nickerchen",
|
||||
"sleepNapCaption": "inkl. {count} Nickerchen",
|
||||
"sleepAwakeningsCaption": "{count} Wachphasen",
|
||||
"expandDay": "Einzelwerte dieses Tages anzeigen",
|
||||
"collapseDay": "Einzelwerte dieses Tages ausblenden",
|
||||
@@ -4395,7 +4398,7 @@
|
||||
"composition": "Zusammensetzung"
|
||||
},
|
||||
"gap": {
|
||||
"fewNights": "noch nicht genug Nächte ({count} von 3)",
|
||||
"fewNights": "noch nicht genug Nächte ({count} von {needed})",
|
||||
"noMainSleep": "letzter Tag nur mit Nickerchen"
|
||||
}
|
||||
},
|
||||
@@ -4664,9 +4667,9 @@
|
||||
"description": "Wie gut sich dein Körper erholt hat: die Erholungs-, Belastungs- und Load-Signale, die dein Wearable über Nacht aufzeichnet.",
|
||||
"scoreCrossLink": "Den zusammengesetzten Erholungs-Score ansehen",
|
||||
"emptyTitle": "Noch keine Erholungsdaten",
|
||||
"empty": "Hier ist noch nichts zu sehen. Diese Seite zeigt die Werte zu Aufladung, Belastung und Trainingslast, die WHOOP und Polar aufzeichnen, und Ouras Resilienz-Stufe. Ouras Readiness erscheint als dein Erholungs-Score, oben verlinkt.",
|
||||
"emptyOuraPermission": "Oura ist verbunden, aber die erteilte Berechtigung umfasst keine Herzgesundheitsdaten, und genau dahinter liegt Ouras Resilienz-Stufe. Verbinde Oura unter Einstellungen, Integrationen einmal neu und erlaube den Zugriff auf Herzgesundheit; die Stufe erscheint dann mit der nächsten Synchronisierung hier. Ouras Readiness ist bereits dein Erholungs-Score, oben verlinkt.",
|
||||
"emptyOuraNoResilience": "Oura ist verbunden, aber für dieses Konto ist noch keine Resilienz-Stufe angekommen. HealthLog fragt bei jeder Synchronisierung danach, sie erscheint hier, sobald Oura eine liefert. Zeigt die Oura-App eine Stufe, nennt die Oura-Karte unter Einstellungen, Integrationen alles, was die Verbindung nicht liefern kann. Ouras Readiness ist bereits dein Erholungs-Score, oben verlinkt.",
|
||||
"empty": "Diese Seite füllt sich mit den Erholungs-, Belastungs- und Trainingslastwerten von WHOOP oder Polar und mit Ouras Resilienz-Stufe.",
|
||||
"emptyOuraPermission": "Ouras Resilienz-Stufe braucht Zugriff auf Herzgesundheitsdaten, also verbinde Oura einmal neu unter Integrationen und erlaube ihn.",
|
||||
"emptyOuraNoResilience": "Oura ist verbunden, und seine Resilienz-Stufe erscheint hier, sobald Oura eine liefert.",
|
||||
"emptyAction": "Integrationen öffnen",
|
||||
"block": {
|
||||
"ansChargeExplainer": "Wie aufgeladen dein autonomes Nervensystem in den Tag startet, ein Recharge-Wert aus deinen nächtlichen Herz- und Atemsignalen.",
|
||||
@@ -4676,7 +4679,8 @@
|
||||
"averageHeartRateExplainer": "Deine durchschnittliche Herzfrequenz über den ganzen Tag bzw. Zyklus, wie dein Wearable sie meldet.",
|
||||
"maxHeartRateExplainer": "Deine höchste Herzfrequenz über den ganzen Tag bzw. Zyklus, wie dein Wearable sie meldet.",
|
||||
"energyExpenditureKjExplainer": "Die Energie, die dein Körper über den Tag verbraucht hat, in Kilojoule, wie dein Gerät sie nativ meldet."
|
||||
}
|
||||
},
|
||||
"loadError": "Deine Erholungswerte konnten nicht geladen werden."
|
||||
},
|
||||
"bodyTempDeviation": {
|
||||
"title": "Körpertemperatur-Abweichung",
|
||||
@@ -7275,7 +7279,8 @@
|
||||
"lastNewValue": "Letzter neuer Wert {relative}",
|
||||
"lastBackground": "Zuletzt im Hintergrund {relative}",
|
||||
"never": "Noch nicht empfangen",
|
||||
"lastBackfill": "Verlauf nachgeladen {relative}"
|
||||
"lastBackfill": "Verlauf nachgeladen {relative}",
|
||||
"lastEnteredHere": "Zuletzt hier eingetragen {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
@@ -11903,7 +11908,8 @@
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "Richtwert {value} {unit}/Tag (EFSA)"
|
||||
"referenceMeta": "Richtwert {value} {unit}/Tag (EFSA)",
|
||||
"loadError": "Wasser konnte nicht geladen werden."
|
||||
},
|
||||
"caffeine": {
|
||||
"ceilingMeta": "Sichere Obergrenze {value} {unit}/Tag (EFSA)",
|
||||
@@ -12324,8 +12330,7 @@
|
||||
"loadError": "Die Aktivität konnte nicht geladen werden.",
|
||||
"opened": "{name} hat deine Akte {count}× geöffnet",
|
||||
"acted": "{name} hat etwas in deiner Akte geändert",
|
||||
"deletedAccount": "Ein gelöschtes Konto",
|
||||
"truncated": "Diese Liste zeigt die neueste Aktivität, nicht die gesamte."
|
||||
"deletedAccount": "Ein gelöschtes Konto"
|
||||
},
|
||||
"delegate": {
|
||||
"addOnlyNote": "Du kannst zu dieser Akte etwas hinzufügen. Einträge ändern oder löschen kann nur {name}."
|
||||
|
||||
+16
-11
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"common": {
|
||||
"listCapped": "Showing the latest {count}; older entries are not listed here.",
|
||||
"save": "Save",
|
||||
"conflictReloaded": "Reloaded from the server. Please make your change again.",
|
||||
"cancel": "Cancel",
|
||||
@@ -55,7 +56,9 @@
|
||||
"errorIdLabel": "Error ID",
|
||||
"errorIdCopy": "Copy error ID",
|
||||
"errorIdCopied": "Error ID copied",
|
||||
"readThrottled": "Too many requests just now. Try again later."
|
||||
"readThrottled": "There are too many requests just now, so try again later.",
|
||||
"sessionEnded": "Your session has ended, so sign in again to continue.",
|
||||
"signIn": "Sign in"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
@@ -843,7 +846,7 @@
|
||||
"nextPage": "Next page",
|
||||
"dailyTotalCaption": "({count} samples)",
|
||||
"openMetricInsights": "Open {type} insights",
|
||||
"sleepNapCaption": "+{count} nap(s)",
|
||||
"sleepNapCaption": "incl. {count} nap(s)",
|
||||
"sleepAwakeningsCaption": "{count} awakening(s)",
|
||||
"expandDay": "Show samples for this day",
|
||||
"collapseDay": "Hide samples for this day",
|
||||
@@ -4395,7 +4398,7 @@
|
||||
"composition": "Composition"
|
||||
},
|
||||
"gap": {
|
||||
"fewNights": "not enough nights yet ({count} of 3)",
|
||||
"fewNights": "not enough nights yet ({count} of {needed})",
|
||||
"noMainSleep": "latest day had only a nap"
|
||||
}
|
||||
},
|
||||
@@ -4664,9 +4667,9 @@
|
||||
"description": "How well your body has bounced back: the recovery, strain and load signals your wearable records overnight.",
|
||||
"scoreCrossLink": "See the composite recovery score",
|
||||
"emptyTitle": "No recovery data yet",
|
||||
"empty": "Nothing to chart here yet. This page shows the recharge, strain and load readings that WHOOP and Polar record, and Oura's resilience level. Oura's readiness appears as your recovery score, linked above.",
|
||||
"emptyOuraPermission": "Oura is connected, but the permission it was given leaves out heart-health data, and Oura's resilience level sits behind it. Reconnect Oura once under Settings, Integrations and allow heart-health access; the level then appears here with the next sync. Oura's readiness is already your recovery score, linked above.",
|
||||
"emptyOuraNoResilience": "Oura is connected, but no resilience level has arrived for this account yet. HealthLog asks Oura for it on every sync, so it appears here as soon as Oura provides one. If the Oura app shows a level, the Oura card under Settings, Integrations names anything the connection cannot deliver. Oura's readiness is already your recovery score, linked above.",
|
||||
"empty": "This page fills with the recharge, strain and load readings from WHOOP or Polar and with Oura's resilience level.",
|
||||
"emptyOuraPermission": "Oura's resilience level needs heart-health access, so reconnect Oura once under Integrations and allow it.",
|
||||
"emptyOuraNoResilience": "Oura is connected, and its resilience level appears here as soon as Oura provides one.",
|
||||
"emptyAction": "Open integrations",
|
||||
"block": {
|
||||
"ansChargeExplainer": "How charged your autonomic nervous system is for the day ahead, a recharge reading drawn from your overnight heart and breathing signals.",
|
||||
@@ -4676,7 +4679,8 @@
|
||||
"averageHeartRateExplainer": "Your average heart rate across the whole day or cycle, as reported by your wearable.",
|
||||
"maxHeartRateExplainer": "Your peak heart rate across the whole day or cycle, as reported by your wearable.",
|
||||
"energyExpenditureKjExplainer": "The energy your body expended over the day, in kilojoules, as your device reports it natively."
|
||||
}
|
||||
},
|
||||
"loadError": "Your recovery readings could not be loaded."
|
||||
},
|
||||
"bodyTempDeviation": {
|
||||
"title": "Body temperature deviation",
|
||||
@@ -7275,7 +7279,8 @@
|
||||
"lastNewValue": "Last new value {relative}",
|
||||
"lastBackground": "Last in background {relative}",
|
||||
"never": "Not received yet",
|
||||
"lastBackfill": "History caught up {relative}"
|
||||
"lastBackfill": "History caught up {relative}",
|
||||
"lastEnteredHere": "Last entered here {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
@@ -11903,7 +11908,8 @@
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "Reference intake {value} {unit}/day (EFSA)"
|
||||
"referenceMeta": "Reference intake {value} {unit}/day (EFSA)",
|
||||
"loadError": "Could not load water."
|
||||
},
|
||||
"caffeine": {
|
||||
"ceilingMeta": "Safe-level ceiling {value} {unit}/day (EFSA)",
|
||||
@@ -12324,8 +12330,7 @@
|
||||
"loadError": "The activity could not be loaded.",
|
||||
"opened": "{name} opened your record {count}×",
|
||||
"acted": "{name} made a change in your record",
|
||||
"deletedAccount": "A deleted account",
|
||||
"truncated": "This list shows the most recent activity, not all of it."
|
||||
"deletedAccount": "A deleted account"
|
||||
},
|
||||
"delegate": {
|
||||
"addOnlyNote": "You can add to this record. Changing or removing entries is something only {name} can do."
|
||||
|
||||
+16
-11
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"common": {
|
||||
"listCapped": "Se muestran los últimos {count}; las entradas anteriores no aparecen aquí.",
|
||||
"save": "Guardar",
|
||||
"conflictReloaded": "Recargado desde el servidor. Vuelve a aplicar tu cambio.",
|
||||
"cancel": "Cancelar",
|
||||
@@ -55,7 +56,9 @@
|
||||
"errorIdLabel": "ID de error",
|
||||
"errorIdCopy": "Copiar ID de error",
|
||||
"errorIdCopied": "ID de error copiado",
|
||||
"readThrottled": "Demasiadas solicitudes ahora mismo. Vuelve a intentarlo más tarde."
|
||||
"readThrottled": "Hay demasiadas solicitudes ahora mismo, así que vuelve a intentarlo más tarde.",
|
||||
"sessionEnded": "Tu sesión ha caducado, así que inicia sesión de nuevo para continuar.",
|
||||
"signIn": "Iniciar sesión"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
@@ -843,7 +846,7 @@
|
||||
"nextPage": "Página siguiente",
|
||||
"dailyTotalCaption": "({count} muestras)",
|
||||
"openMetricInsights": "Abrir análisis de {type}",
|
||||
"sleepNapCaption": "+{count} siesta(s)",
|
||||
"sleepNapCaption": "incl. {count} siesta(s)",
|
||||
"sleepAwakeningsCaption": "{count} despertar(es)",
|
||||
"expandDay": "Mostrar las muestras de este día",
|
||||
"collapseDay": "Ocultar las muestras de este día",
|
||||
@@ -4395,7 +4398,7 @@
|
||||
"composition": "Composición"
|
||||
},
|
||||
"gap": {
|
||||
"fewNights": "aún no hay suficientes noches ({count} de 3)",
|
||||
"fewNights": "aún no hay suficientes noches ({count} de {needed})",
|
||||
"noMainSleep": "el último día solo tuvo una siesta"
|
||||
}
|
||||
},
|
||||
@@ -4664,9 +4667,9 @@
|
||||
"description": "Cómo se ha recuperado tu cuerpo: las señales de recuperación, esfuerzo y carga que tu wearable registra durante la noche.",
|
||||
"scoreCrossLink": "Ver la puntuación de recuperación combinada",
|
||||
"emptyTitle": "Aún no hay datos de recuperación",
|
||||
"empty": "Aún no hay nada que mostrar aquí. Esta página muestra las lecturas de recarga, esfuerzo y carga que registran WHOOP y Polar, y el nivel de resiliencia de Oura. La preparación (readiness) de Oura aparece como tu puntuación de recuperación, enlazada arriba.",
|
||||
"emptyOuraPermission": "Oura está conectado, pero el permiso concedido no incluye los datos de salud cardíaca, y el nivel de resiliencia de Oura depende de ellos. Vuelve a conectar Oura una vez en Ajustes, Integraciones y permite el acceso a la salud cardíaca; el nivel aparecerá aquí con la próxima sincronización. La preparación (readiness) de Oura ya es tu puntuación de recuperación, enlazada arriba.",
|
||||
"emptyOuraNoResilience": "Oura está conectado, pero todavía no ha llegado ningún nivel de resiliencia para esta cuenta. HealthLog lo pide en cada sincronización, así que aparecerá aquí en cuanto Oura lo proporcione. Si la app de Oura muestra un nivel, la tarjeta de Oura en Ajustes, Integraciones indica todo lo que la conexión no puede entregar. La preparación (readiness) de Oura ya es tu puntuación de recuperación, enlazada arriba.",
|
||||
"empty": "Esta página se llena con los valores de recuperación, esfuerzo y carga de WHOOP o Polar y con el nivel de resiliencia de Oura.",
|
||||
"emptyOuraPermission": "El nivel de resiliencia de Oura necesita acceso a los datos de salud cardíaca, así que vuelve a conectar Oura una vez en Integraciones y permítelo.",
|
||||
"emptyOuraNoResilience": "Oura está conectado y su nivel de resiliencia aparecerá aquí en cuanto Oura proporcione uno.",
|
||||
"emptyAction": "Abrir integraciones",
|
||||
"block": {
|
||||
"ansChargeExplainer": "Cuán cargado está tu sistema nervioso autónomo para el día: una lectura de recarga obtenida de tus señales nocturnas de corazón y respiración.",
|
||||
@@ -4676,7 +4679,8 @@
|
||||
"averageHeartRateExplainer": "Tu frecuencia cardíaca media a lo largo de todo el día o ciclo, según informa tu wearable.",
|
||||
"maxHeartRateExplainer": "Tu frecuencia cardíaca máxima a lo largo de todo el día o ciclo, según informa tu wearable.",
|
||||
"energyExpenditureKjExplainer": "La energía que tu cuerpo gastó durante el día, en kilojulios, tal como tu dispositivo la informa de forma nativa."
|
||||
}
|
||||
},
|
||||
"loadError": "No se pudieron cargar tus valores de recuperación."
|
||||
},
|
||||
"bodyTempDeviation": {
|
||||
"title": "Desviación de la temperatura corporal",
|
||||
@@ -7275,7 +7279,8 @@
|
||||
"lastNewValue": "Último valor nuevo {relative}",
|
||||
"lastBackground": "Última vez en segundo plano {relative}",
|
||||
"never": "Aún no recibido",
|
||||
"lastBackfill": "Historial recuperado {relative}"
|
||||
"lastBackfill": "Historial recuperado {relative}",
|
||||
"lastEnteredHere": "Último registro aquí {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
@@ -11903,7 +11908,8 @@
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "Ingesta de referencia {value} {unit}/día (EFSA)"
|
||||
"referenceMeta": "Ingesta de referencia {value} {unit}/día (EFSA)",
|
||||
"loadError": "No se pudo cargar el agua."
|
||||
},
|
||||
"caffeine": {
|
||||
"ceilingMeta": "Límite de seguridad {value} {unit}/día (EFSA)",
|
||||
@@ -12324,8 +12330,7 @@
|
||||
"loadError": "No se pudo cargar la actividad.",
|
||||
"opened": "{name} abrió tu historial {count}×",
|
||||
"acted": "{name} hizo un cambio en tu historial",
|
||||
"deletedAccount": "Una cuenta eliminada",
|
||||
"truncated": "Esta lista muestra la actividad más reciente, no toda."
|
||||
"deletedAccount": "Una cuenta eliminada"
|
||||
},
|
||||
"delegate": {
|
||||
"addOnlyNote": "Puedes añadir cosas a este historial. Modificar o borrar entradas solo lo puede hacer {name}."
|
||||
|
||||
+16
-11
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"common": {
|
||||
"listCapped": "Seuls les {count} derniers sont affichés ; les entrées plus anciennes ne figurent pas ici.",
|
||||
"save": "Enregistrer",
|
||||
"conflictReloaded": "Rechargé depuis le serveur. Refais ta modification.",
|
||||
"cancel": "Annuler",
|
||||
@@ -55,7 +56,9 @@
|
||||
"errorIdLabel": "ID d'erreur",
|
||||
"errorIdCopy": "Copier l'ID d'erreur",
|
||||
"errorIdCopied": "ID d'erreur copié",
|
||||
"readThrottled": "Trop de requêtes pour le moment. Réessaie plus tard."
|
||||
"readThrottled": "Il y a trop de requêtes pour le moment, réessayez plus tard.",
|
||||
"sessionEnded": "Votre session a expiré, reconnectez-vous pour continuer.",
|
||||
"signIn": "Se connecter"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
@@ -843,7 +846,7 @@
|
||||
"nextPage": "Page suivante",
|
||||
"dailyTotalCaption": "({count} échantillons)",
|
||||
"openMetricInsights": "Ouvrir l'analyse {type}",
|
||||
"sleepNapCaption": "+{count} sieste(s)",
|
||||
"sleepNapCaption": "dont {count} sieste(s)",
|
||||
"sleepAwakeningsCaption": "{count} réveil(s)",
|
||||
"expandDay": "Afficher les échantillons de ce jour",
|
||||
"collapseDay": "Masquer les échantillons de ce jour",
|
||||
@@ -4395,7 +4398,7 @@
|
||||
"composition": "Composition"
|
||||
},
|
||||
"gap": {
|
||||
"fewNights": "pas encore assez de nuits ({count} sur 3)",
|
||||
"fewNights": "pas encore assez de nuits ({count} sur {needed})",
|
||||
"noMainSleep": "le dernier jour n'a eu qu'une sieste"
|
||||
}
|
||||
},
|
||||
@@ -4664,9 +4667,9 @@
|
||||
"description": "À quel point votre corps a récupéré : les signaux de récupération, de charge et d'effort que votre wearable enregistre la nuit.",
|
||||
"scoreCrossLink": "Voir le score de récupération composite",
|
||||
"emptyTitle": "Pas encore de données de récupération",
|
||||
"empty": "Rien à afficher ici pour l'instant. Cette page montre les mesures de recharge, d'effort et de charge qu'enregistrent WHOOP et Polar, ainsi que le niveau de résilience d'Oura. La disponibilité (readiness) d'Oura apparaît comme votre score de récupération, en lien ci-dessus.",
|
||||
"emptyOuraPermission": "Oura est connecté, mais l'autorisation accordée n'inclut pas les données de santé cardiaque, dont dépend le niveau de résilience d'Oura. Reconnectez Oura une fois dans Réglages, Intégrations et autorisez l'accès à la santé cardiaque ; le niveau apparaîtra ici à la prochaine synchronisation. La disponibilité (readiness) d'Oura est déjà votre score de récupération, en lien ci-dessus.",
|
||||
"emptyOuraNoResilience": "Oura est connecté, mais aucun niveau de résilience n'est encore arrivé pour ce compte. HealthLog le demande à chaque synchronisation, il apparaîtra ici dès qu'Oura en fournira un. Si l'app Oura affiche un niveau, la carte Oura dans Réglages, Intégrations indique tout ce que la connexion ne peut pas fournir. La disponibilité (readiness) d'Oura est déjà votre score de récupération, en lien ci-dessus.",
|
||||
"empty": "Cette page se remplit avec les valeurs de récupération, d'effort et de charge de WHOOP ou Polar et avec le niveau de résilience d'Oura.",
|
||||
"emptyOuraPermission": "Le niveau de résilience d'Oura demande l'accès aux données de santé cardiaque : reconnectez Oura une fois dans Intégrations et autorisez-le.",
|
||||
"emptyOuraNoResilience": "Oura est connecté, et son niveau de résilience apparaîtra ici dès qu'Oura en fournira un.",
|
||||
"emptyAction": "Ouvrir les intégrations",
|
||||
"block": {
|
||||
"ansChargeExplainer": "À quel point votre système nerveux autonome est rechargé pour la journée : une mesure de recharge issue de vos signaux nocturnes de cœur et de respiration.",
|
||||
@@ -4676,7 +4679,8 @@
|
||||
"averageHeartRateExplainer": "Votre fréquence cardiaque moyenne sur toute la journée ou le cycle, telle que rapportée par votre wearable.",
|
||||
"maxHeartRateExplainer": "Votre fréquence cardiaque maximale sur toute la journée ou le cycle, telle que rapportée par votre wearable.",
|
||||
"energyExpenditureKjExplainer": "L'énergie que votre corps a dépensée sur la journée, en kilojoules, telle que votre appareil la rapporte nativement."
|
||||
}
|
||||
},
|
||||
"loadError": "Vos valeurs de récupération n'ont pas pu être chargées."
|
||||
},
|
||||
"bodyTempDeviation": {
|
||||
"title": "Écart de température corporelle",
|
||||
@@ -7275,7 +7279,8 @@
|
||||
"lastNewValue": "Dernière nouvelle valeur {relative}",
|
||||
"lastBackground": "Dernière fois en arrière-plan {relative}",
|
||||
"never": "Pas encore reçu",
|
||||
"lastBackfill": "Historique rattrapé {relative}"
|
||||
"lastBackfill": "Historique rattrapé {relative}",
|
||||
"lastEnteredHere": "Dernière saisie ici {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
@@ -11903,7 +11908,8 @@
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "Apport de référence {value} {unit}/jour (EFSA)"
|
||||
"referenceMeta": "Apport de référence {value} {unit}/jour (EFSA)",
|
||||
"loadError": "Impossible de charger l'eau."
|
||||
},
|
||||
"caffeine": {
|
||||
"ceilingMeta": "Plafond de sécurité {value} {unit}/jour (EFSA)",
|
||||
@@ -12324,8 +12330,7 @@
|
||||
"loadError": "L'activité n'a pas pu être chargée.",
|
||||
"opened": "{name} a ouvert votre dossier {count}×",
|
||||
"acted": "{name} a fait une modification dans votre dossier",
|
||||
"deletedAccount": "Un compte supprimé",
|
||||
"truncated": "Cette liste montre l'activité la plus récente, pas la totalité."
|
||||
"deletedAccount": "Un compte supprimé"
|
||||
},
|
||||
"delegate": {
|
||||
"addOnlyNote": "Vous pouvez ajouter des éléments à ce dossier. Seul {name} peut modifier ou supprimer des entrées."
|
||||
|
||||
+16
-11
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"common": {
|
||||
"listCapped": "Sono mostrati gli ultimi {count}; le voci più vecchie non sono elencate qui.",
|
||||
"save": "Salva",
|
||||
"conflictReloaded": "Ricaricato dal server. Applica di nuovo la tua modifica.",
|
||||
"cancel": "Annulla",
|
||||
@@ -55,7 +56,9 @@
|
||||
"errorIdLabel": "ID errore",
|
||||
"errorIdCopy": "Copia ID errore",
|
||||
"errorIdCopied": "ID errore copiato",
|
||||
"readThrottled": "Troppe richieste in questo momento. Riprova più tardi."
|
||||
"readThrottled": "Ci sono troppe richieste in questo momento, quindi riprova più tardi.",
|
||||
"sessionEnded": "La tua sessione è scaduta, quindi accedi di nuovo per continuare.",
|
||||
"signIn": "Accedi"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
@@ -843,7 +846,7 @@
|
||||
"nextPage": "Pagina successiva",
|
||||
"dailyTotalCaption": "({count} campioni)",
|
||||
"openMetricInsights": "Apri l'analisi {type}",
|
||||
"sleepNapCaption": "+{count} pisolino/i",
|
||||
"sleepNapCaption": "incl. {count} pisolino/i",
|
||||
"sleepAwakeningsCaption": "{count} risveglio/i",
|
||||
"expandDay": "Mostra i campioni di questa giornata",
|
||||
"collapseDay": "Nascondi i campioni di questa giornata",
|
||||
@@ -4395,7 +4398,7 @@
|
||||
"composition": "Composizione"
|
||||
},
|
||||
"gap": {
|
||||
"fewNights": "notti ancora insufficienti ({count} su 3)",
|
||||
"fewNights": "notti ancora insufficienti ({count} su {needed})",
|
||||
"noMainSleep": "l'ultimo giorno solo un pisolino"
|
||||
}
|
||||
},
|
||||
@@ -4664,9 +4667,9 @@
|
||||
"description": "Quanto bene il tuo corpo si è ripreso: i segnali di recupero, sforzo e carico che il tuo wearable registra durante la notte.",
|
||||
"scoreCrossLink": "Vedi il punteggio di recupero composito",
|
||||
"emptyTitle": "Ancora nessun dato di recupero",
|
||||
"empty": "Qui non c'è ancora nulla da mostrare. Questa pagina mostra i valori di ricarica, sforzo e carico che registrano WHOOP e Polar, e il livello di resilienza di Oura. La prontezza (readiness) di Oura compare come punteggio di recupero, collegato qui sopra.",
|
||||
"emptyOuraPermission": "Oura è collegato, ma l'autorizzazione concessa non include i dati sulla salute cardiaca, da cui dipende il livello di resilienza di Oura. Ricollega Oura una volta in Impostazioni, Integrazioni e consenti l'accesso alla salute cardiaca; il livello comparirà qui con la prossima sincronizzazione. La prontezza (readiness) di Oura è già il tuo punteggio di recupero, collegato qui sopra.",
|
||||
"emptyOuraNoResilience": "Oura è collegato, ma per questo account non è ancora arrivato alcun livello di resilienza. HealthLog lo richiede a ogni sincronizzazione, quindi comparirà qui non appena Oura ne fornirà uno. Se l'app Oura mostra un livello, la scheda Oura in Impostazioni, Integrazioni indica tutto ciò che la connessione non può fornire. La prontezza (readiness) di Oura è già il tuo punteggio di recupero, collegato qui sopra.",
|
||||
"empty": "Questa pagina si riempie con i valori di recupero, sforzo e carico di WHOOP o Polar e con il livello di resilienza di Oura.",
|
||||
"emptyOuraPermission": "Il livello di resilienza di Oura richiede l'accesso ai dati sulla salute del cuore, quindi ricollega Oura una volta in Integrazioni e consentilo.",
|
||||
"emptyOuraNoResilience": "Oura è collegato e il suo livello di resilienza apparirà qui non appena Oura ne fornirà uno.",
|
||||
"emptyAction": "Apri integrazioni",
|
||||
"block": {
|
||||
"ansChargeExplainer": "Quanto è carico il tuo sistema nervoso autonomo per la giornata: una lettura di ricarica ricavata dai segnali notturni di cuore e respiro.",
|
||||
@@ -4676,7 +4679,8 @@
|
||||
"averageHeartRateExplainer": "La tua frequenza cardiaca media nell'arco dell'intera giornata o ciclo, come riportata dal tuo wearable.",
|
||||
"maxHeartRateExplainer": "La tua frequenza cardiaca massima nell'arco dell'intera giornata o ciclo, come riportata dal tuo wearable.",
|
||||
"energyExpenditureKjExplainer": "L'energia che il tuo corpo ha consumato nella giornata, in kilojoule, come la riporta nativamente il tuo dispositivo."
|
||||
}
|
||||
},
|
||||
"loadError": "Non è stato possibile caricare i tuoi valori di recupero."
|
||||
},
|
||||
"bodyTempDeviation": {
|
||||
"title": "Deviazione della temperatura corporea",
|
||||
@@ -7275,7 +7279,8 @@
|
||||
"lastNewValue": "Ultimo nuovo valore {relative}",
|
||||
"lastBackground": "Ultima volta in background {relative}",
|
||||
"never": "Non ancora ricevuto",
|
||||
"lastBackfill": "Storico recuperato {relative}"
|
||||
"lastBackfill": "Storico recuperato {relative}",
|
||||
"lastEnteredHere": "Ultimo inserimento qui {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
@@ -11903,7 +11908,8 @@
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "Apporto di riferimento {value} {unit}/giorno (EFSA)"
|
||||
"referenceMeta": "Apporto di riferimento {value} {unit}/giorno (EFSA)",
|
||||
"loadError": "Impossibile caricare l'acqua."
|
||||
},
|
||||
"caffeine": {
|
||||
"ceilingMeta": "Limite di sicurezza {value} {unit}/giorno (EFSA)",
|
||||
@@ -12324,8 +12330,7 @@
|
||||
"loadError": "Non è stato possibile caricare l'attività.",
|
||||
"opened": "{name} ha aperto la tua cartella {count}×",
|
||||
"acted": "{name} ha fatto una modifica nella tua cartella",
|
||||
"deletedAccount": "Un account eliminato",
|
||||
"truncated": "Questo elenco mostra l'attività più recente, non tutta."
|
||||
"deletedAccount": "Un account eliminato"
|
||||
},
|
||||
"delegate": {
|
||||
"addOnlyNote": "Puoi aggiungere voci a questa cartella. Solo {name} può modificarle o eliminarle."
|
||||
|
||||
+17
-12
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"common": {
|
||||
"listCapped": "최근 {count}개만 표시되며, 이전 항목은 여기에 나오지 않습니다.",
|
||||
"save": "저장",
|
||||
"conflictReloaded": "서버에서 다시 불러왔어요. 변경 내용을 다시 입력해 주세요.",
|
||||
"cancel": "취소",
|
||||
@@ -55,7 +56,9 @@
|
||||
"errorIdLabel": "오류 ID",
|
||||
"errorIdCopy": "오류 ID 복사",
|
||||
"errorIdCopied": "오류 ID 복사됨",
|
||||
"readThrottled": "지금은 요청이 너무 많아요. 나중에 다시 시도해 주세요."
|
||||
"readThrottled": "지금은 요청이 너무 많으니 나중에 다시 시도해 주세요.",
|
||||
"sessionEnded": "세션이 만료되었으니 계속하려면 다시 로그인해 주세요.",
|
||||
"signIn": "로그인"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
@@ -843,7 +846,7 @@
|
||||
"nextPage": "다음 쪽",
|
||||
"dailyTotalCaption": "({count}개 샘플)",
|
||||
"openMetricInsights": "{type} 인사이트 열기",
|
||||
"sleepNapCaption": "+낮잠 {count}회",
|
||||
"sleepNapCaption": "낮잠 {count}회 포함",
|
||||
"sleepAwakeningsCaption": "깬 횟수 {count}회",
|
||||
"expandDay": "이 날의 샘플 보기",
|
||||
"collapseDay": "이 날의 샘플 숨기기",
|
||||
@@ -4395,8 +4398,8 @@
|
||||
"composition": "구성"
|
||||
},
|
||||
"gap": {
|
||||
"fewNights": "아직 밤이 부족함 ({count}/3)",
|
||||
"noMainSleep": "최근 하루는 낮잠만 있음"
|
||||
"fewNights": "아직 밤이 부족해요 ({needed}일 중 {count}일)",
|
||||
"noMainSleep": "최근 하루는 낮잠만 있었어요"
|
||||
}
|
||||
},
|
||||
"READINESS": {
|
||||
@@ -4664,9 +4667,9 @@
|
||||
"description": "몸이 얼마나 회복했는지: 웨어러블이 밤사이 기록한 회복·부하 신호예요.",
|
||||
"scoreCrossLink": "종합 회복 점수 보기",
|
||||
"emptyTitle": "아직 회복 데이터가 없어요",
|
||||
"empty": "아직 여기에 표시할 내용이 없어요. 이 페이지는 WHOOP과 Polar가 기록하는 충전, 부하, 운동량 값과 Oura의 회복 탄력성 단계를 보여줘요. Oura의 준비도(Readiness)는 위에 연결된 회복 점수에 표시돼요.",
|
||||
"emptyOuraPermission": "Oura는 연결되어 있지만 허용된 권한에 심장 건강 데이터가 빠져 있어요. Oura의 회복 탄력성 단계는 바로 이 데이터에 속해요. 설정, 통합에서 Oura를 한 번 다시 연결하고 심장 건강 접근을 허용하면 다음 동기화 때 여기에 단계가 표시돼요. Oura의 준비도(Readiness)는 이미 위에 연결된 회복 점수에 표시돼요.",
|
||||
"emptyOuraNoResilience": "Oura가 연결되어 있지만 이 계정의 회복 탄력성 단계가 아직 도착하지 않았어요. HealthLog는 동기화할 때마다 요청하므로 Oura가 보내는 즉시 여기에 표시돼요. Oura 앱에 단계가 보인다면 설정, 통합의 Oura 카드에서 이 연결이 가져올 수 없는 항목을 확인할 수 있어요. Oura의 준비도(Readiness)는 이미 위에 연결된 회복 점수에 표시돼요.",
|
||||
"empty": "이 페이지에는 WHOOP 또는 Polar의 회복, 부하, 훈련 부하 값과 Oura의 회복탄력성 수준이 표시돼요.",
|
||||
"emptyOuraPermission": "Oura의 회복탄력성 수준에는 심장 건강 데이터 접근 권한이 필요하니, 연동에서 Oura를 한 번 다시 연결하고 허용해 주세요.",
|
||||
"emptyOuraNoResilience": "Oura가 연결되어 있으며, Oura가 회복탄력성 수준을 제공하는 즉시 여기에 표시돼요.",
|
||||
"emptyAction": "통합 열기",
|
||||
"block": {
|
||||
"ansChargeExplainer": "오늘 하루를 맞을 자율신경계의 충전 상태예요. 밤사이 심장·호흡 신호에서 읽어낸 재충전 지표예요.",
|
||||
@@ -4676,7 +4679,8 @@
|
||||
"averageHeartRateExplainer": "웨어러블이 보고한 하루 또는 주기 전체의 평균 심박수예요.",
|
||||
"maxHeartRateExplainer": "웨어러블이 보고한 하루 또는 주기 전체의 최고 심박수예요.",
|
||||
"energyExpenditureKjExplainer": "기기가 자체 단위로 보고한, 하루 동안 몸이 쓴 에너지예요(킬로줄)."
|
||||
}
|
||||
},
|
||||
"loadError": "회복 값을 불러오지 못했어요."
|
||||
},
|
||||
"bodyTempDeviation": {
|
||||
"title": "체온 편차",
|
||||
@@ -7275,7 +7279,8 @@
|
||||
"lastNewValue": "마지막 새 값 {relative}",
|
||||
"lastBackground": "마지막 백그라운드 수신 {relative}",
|
||||
"never": "아직 수신되지 않음",
|
||||
"lastBackfill": "기록 보충 {relative}"
|
||||
"lastBackfill": "기록 보충 {relative}",
|
||||
"lastEnteredHere": "여기에서 마지막 입력 {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
@@ -11903,7 +11908,8 @@
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "권장 섭취량 {value} {unit}/일 (EFSA)"
|
||||
"referenceMeta": "권장 섭취량 {value} {unit}/일 (EFSA)",
|
||||
"loadError": "수분을 불러오지 못했어요."
|
||||
},
|
||||
"caffeine": {
|
||||
"ceilingMeta": "안전 상한 {value} {unit}/일 (EFSA)",
|
||||
@@ -12324,8 +12330,7 @@
|
||||
"loadError": "활동 내역을 불러오지 못했어요.",
|
||||
"opened": "{name} 님이 내 기록을 {count}번 열었어요",
|
||||
"acted": "{name} 님이 내 기록을 수정했어요",
|
||||
"deletedAccount": "삭제된 계정",
|
||||
"truncated": "이 목록은 최근 활동만 보여줘요. 전부는 아니에요."
|
||||
"deletedAccount": "삭제된 계정"
|
||||
},
|
||||
"delegate": {
|
||||
"addOnlyNote": "이 기록에 추가할 수 있어요. 수정과 삭제는 {name} 님만 할 수 있어요."
|
||||
|
||||
+13379
File diff suppressed because it is too large.
Load diff
+16
-11
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"common": {
|
||||
"listCapped": "Widać tylko ostatnie {count}; starsze wpisy nie są tu wymienione.",
|
||||
"save": "Zapisz",
|
||||
"conflictReloaded": "Odświeżono z serwera. Wprowadź zmianę jeszcze raz.",
|
||||
"cancel": "Anuluj",
|
||||
@@ -55,7 +56,9 @@
|
||||
"errorIdLabel": "ID błędu",
|
||||
"errorIdCopy": "Kopiuj ID błędu",
|
||||
"errorIdCopied": "Skopiowano ID błędu",
|
||||
"readThrottled": "Zbyt wiele zapytań w tej chwili. Spróbuj ponownie później."
|
||||
"readThrottled": "W tej chwili jest zbyt wiele zapytań, więc spróbuj ponownie później.",
|
||||
"sessionEnded": "Twoja sesja wygasła, więc zaloguj się ponownie, aby kontynuować.",
|
||||
"signIn": "Zaloguj się"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
@@ -843,7 +846,7 @@
|
||||
"nextPage": "Następna strona",
|
||||
"dailyTotalCaption": "({count} pomiarów)",
|
||||
"openMetricInsights": "Otwórz analizę {type}",
|
||||
"sleepNapCaption": "+{count} drzemka/i",
|
||||
"sleepNapCaption": "w tym {count} drzemka/i",
|
||||
"sleepAwakeningsCaption": "{count} przebudzenia",
|
||||
"expandDay": "Pokaż pomiary z tego dnia",
|
||||
"collapseDay": "Ukryj pomiary z tego dnia",
|
||||
@@ -4395,7 +4398,7 @@
|
||||
"composition": "Struktura"
|
||||
},
|
||||
"gap": {
|
||||
"fewNights": "za mało nocy ({count} z 3)",
|
||||
"fewNights": "za mało nocy ({count} z {needed})",
|
||||
"noMainSleep": "ostatni dzień tylko z drzemką"
|
||||
}
|
||||
},
|
||||
@@ -4664,9 +4667,9 @@
|
||||
"description": "Jak dobrze Twój organizm się zregenerował: sygnały regeneracji, obciążenia i load, które Twoje urządzenie rejestruje w nocy.",
|
||||
"scoreCrossLink": "Zobacz łączny wynik regeneracji",
|
||||
"emptyTitle": "Brak danych o regeneracji",
|
||||
"empty": "Na razie nie ma tu nic do pokazania. Ta strona pokazuje odczyty ładowania, wysiłku i obciążenia, które rejestrują WHOOP i Polar, oraz poziom odporności z Oura. Gotowość (readiness) z Oura pojawia się jako wynik regeneracji, link powyżej.",
|
||||
"emptyOuraPermission": "Oura jest połączona, ale udzielone uprawnienie nie obejmuje danych o zdrowiu serca, od których zależy poziom odporności z Oura. Połącz Oura ponownie raz w Ustawieniach, Integracjach i zezwól na dostęp do zdrowia serca; poziom pojawi się tutaj przy następnej synchronizacji. Gotowość (readiness) z Oura jest już Twoim wynikiem regeneracji, link powyżej.",
|
||||
"emptyOuraNoResilience": "Oura jest połączona, ale dla tego konta nie dotarł jeszcze żaden poziom odporności. HealthLog pyta o niego przy każdej synchronizacji, więc pojawi się tutaj, gdy tylko Oura go dostarczy. Jeśli aplikacja Oura pokazuje poziom, karta Oura w Ustawieniach, Integracjach wymienia wszystko, czego połączenie nie może dostarczyć. Gotowość (readiness) z Oura jest już Twoim wynikiem regeneracji, link powyżej.",
|
||||
"empty": "Ta strona wypełnia się wartościami regeneracji, obciążenia i obciążenia treningowego z WHOOP lub Polar oraz poziomem odporności z Oura.",
|
||||
"emptyOuraPermission": "Poziom odporności Oura wymaga dostępu do danych o zdrowiu serca, więc połącz Oura ponownie raz w Integracjach i zezwól na niego.",
|
||||
"emptyOuraNoResilience": "Oura jest połączona, a jej poziom odporności pojawi się tutaj, gdy tylko Oura go dostarczy.",
|
||||
"emptyAction": "Otwórz integracje",
|
||||
"block": {
|
||||
"ansChargeExplainer": "Jak naładowany jest Twój autonomiczny układ nerwowy na dany dzień: odczyt regeneracji oparty na nocnych sygnałach serca i oddechu.",
|
||||
@@ -4676,7 +4679,8 @@
|
||||
"averageHeartRateExplainer": "Twoje średnie tętno w ciągu całego dnia lub cyklu, zgodnie z odczytem Twojego urządzenia.",
|
||||
"maxHeartRateExplainer": "Twoje maksymalne tętno w ciągu całego dnia lub cyklu, zgodnie z odczytem Twojego urządzenia.",
|
||||
"energyExpenditureKjExplainer": "Energia, którą Twój organizm wydatkował w ciągu dnia, w kilodżulach, zgodnie z natywnym odczytem urządzenia."
|
||||
}
|
||||
},
|
||||
"loadError": "Nie udało się załadować Twoich wartości regeneracji."
|
||||
},
|
||||
"bodyTempDeviation": {
|
||||
"title": "Odchylenie temperatury ciała",
|
||||
@@ -7275,7 +7279,8 @@
|
||||
"lastNewValue": "Ostatnia nowa wartość {relative}",
|
||||
"lastBackground": "Ostatnio w tle {relative}",
|
||||
"never": "Jeszcze nie odebrano",
|
||||
"lastBackfill": "Historia uzupełniona {relative}"
|
||||
"lastBackfill": "Historia uzupełniona {relative}",
|
||||
"lastEnteredHere": "Ostatni wpis tutaj {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
@@ -11903,7 +11908,8 @@
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"referenceMeta": "Zalecane spożycie {value} {unit}/dzień (EFSA)"
|
||||
"referenceMeta": "Zalecane spożycie {value} {unit}/dzień (EFSA)",
|
||||
"loadError": "Nie udało się załadować wody."
|
||||
},
|
||||
"caffeine": {
|
||||
"ceilingMeta": "Bezpieczny limit {value} {unit}/dzień (EFSA)",
|
||||
@@ -12324,8 +12330,7 @@
|
||||
"loadError": "Nie udało się wczytać aktywności.",
|
||||
"opened": "{name} otworzył twoją kartę {count}×",
|
||||
"acted": "{name} wprowadził zmianę w twojej karcie",
|
||||
"deletedAccount": "Usunięte konto",
|
||||
"truncated": "Ta lista pokazuje najnowszą aktywność, a nie całą."
|
||||
"deletedAccount": "Usunięte konto"
|
||||
},
|
||||
"delegate": {
|
||||
"addOnlyNote": "Możesz dodawać wpisy do tej karty. Zmieniać je i usuwać może tylko {name}."
|
||||
|
||||
+8
-8
@@ -45,7 +45,7 @@ const nextConfig: NextConfig = {
|
||||
NEXT_PUBLIC_APP_VERSION: PKG_VERSION,
|
||||
NEXT_PUBLIC_I18N_CATALOG_VERSION: catalogVersion(),
|
||||
},
|
||||
// v1.4.33 IW2 — strip `console.*` calls from the production bundle
|
||||
// v1.4.33 — strip `console.*` calls from the production bundle
|
||||
// (keep `console.error` + `console.warn` so the GlitchTip reporter
|
||||
// and prod-side debug rails still surface). The Lighthouse audit
|
||||
// flagged ~211 KiB of bundled JS as "unminified" — Turbopack already
|
||||
@@ -60,7 +60,7 @@ const nextConfig: NextConfig = {
|
||||
? { exclude: ["error", "warn"] }
|
||||
: false,
|
||||
},
|
||||
// v1.4.33 IW2 — bfcache hygiene. `Permissions-Policy: unload=()`
|
||||
// v1.4.33 — bfcache hygiene. `Permissions-Policy: unload=()`
|
||||
// tells the browser the page does not need the `unload` event,
|
||||
// which Chromium uses as a hint to admit the page to the
|
||||
// back/forward cache on navigation away. Pair with the absence of
|
||||
@@ -69,7 +69,7 @@ const nextConfig: NextConfig = {
|
||||
// headers already live on the response via the standalone runtime;
|
||||
// we add only the bfcache hint here.
|
||||
//
|
||||
// v1.4.34 IW-A — second rule layers the bfcache-friendly
|
||||
// v1.4.34 — second rule layers the bfcache-friendly
|
||||
// `Cache-Control` directive onto every authenticated HTML page
|
||||
// response (the source negative-lookahead excludes `/api/*` and
|
||||
// `/_next/*` so static assets keep their immutable caching and API
|
||||
@@ -151,10 +151,10 @@ const nextConfig: NextConfig = {
|
||||
destination: "/coach",
|
||||
permanent: true,
|
||||
},
|
||||
// v1.18.1 (D4) — Sources is a standalone `/settings/sources` route again
|
||||
// v1.18.1 — Sources is a standalone `/settings/sources` route again
|
||||
// (split back out of the Integrations sub-tabs), so the v1.18.0 redirect
|
||||
// to `/settings/integrations` was dropped.
|
||||
// v1.18.0 (S4) — the standalone "Erinnerungen" hub at
|
||||
// v1.18.0 — the standalone "Erinnerungen" hub at
|
||||
// `/settings/reminders` was a link-only page that duplicated the
|
||||
// canonical editors. Reminder TYPES now live in Notifications, each
|
||||
// gated on its module. 301-redirect so bookmarks, the PWA's cached
|
||||
@@ -275,7 +275,7 @@ const nextConfig: NextConfig = {
|
||||
"pdfjs-dist",
|
||||
"@napi-rs/canvas",
|
||||
],
|
||||
// v1.4.25 Fix-G — `src/lib/ai/prompts/safety-contracts.ts` reads its
|
||||
// v1.4.25 — `src/lib/ai/prompts/safety-contracts.ts` reads its
|
||||
// sibling YAML files at runtime via `__dirname + readFileSync`. The
|
||||
// standalone bundler ships the JS but not the YAML, so the build
|
||||
// crashed during page-data collection with ENOENT once the Turbopack
|
||||
@@ -309,7 +309,7 @@ const nextConfig: NextConfig = {
|
||||
"./node_modules/.pnpm/@napi-rs+canvas-linux-arm64-musl@*/node_modules/@napi-rs/canvas-linux-arm64-musl/**",
|
||||
],
|
||||
},
|
||||
// v1.4.34 IW-A — silence the Turbopack NFT trace warnings emitted
|
||||
// v1.4.34 — silence the Turbopack NFT trace warnings emitted
|
||||
// during `next build`. The tracer follows the `MAXMIND_LICENSE_KEY`
|
||||
// env access in `src/lib/geo.ts` back into the config file, then
|
||||
// emits "cannot be traced" warnings for paths it tried to walk
|
||||
@@ -336,7 +336,7 @@ const nextConfig: NextConfig = {
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.4.28 R3d — opt-in bundle analyzer behind `ANALYZE=1`.
|
||||
* v1.4.28 — opt-in bundle analyzer behind `ANALYZE=1`.
|
||||
*
|
||||
* `pnpm analyze` (defined in `package.json`) sets the env var and
|
||||
* runs `next build`; the analyzer writes static HTML reports to
|
||||
|
||||
+1
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.43.6",
|
||||
"version": "1.43.7",
|
||||
"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",
|
||||
@@ -58,7 +58,6 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.9.1",
|
||||
"@modelcontextprotocol/sdk": "^1.32.1",
|
||||
"@napi-rs/canvas": "1.0.11",
|
||||
"@node-rs/argon2": "^2.2.2",
|
||||
@@ -81,7 +80,6 @@
|
||||
"lucide-react": "^1.56.0",
|
||||
"mmdb-lib": "^3.0.3",
|
||||
"next": "16.3.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "10.1.0",
|
||||
"otpauth": "^9.5.2",
|
||||
"p-limit": "^7.3.3",
|
||||
@@ -93,7 +91,6 @@
|
||||
"react": "19.3.0",
|
||||
"react-day-picker": "^10.0.2",
|
||||
"react-dom": "19.3.0",
|
||||
"react-hook-form": "7.90.0",
|
||||
"recharts": "^3.10.1",
|
||||
"rrule": "^2.8.1",
|
||||
"sax": "^1.6.1",
|
||||
@@ -121,7 +118,6 @@
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"@vitest/coverage-v8": "^5.0.4",
|
||||
"ajv": "^8.20.0",
|
||||
"eslint": "^9",
|
||||
|
||||
@@ -251,7 +251,7 @@ export default defineConfig({
|
||||
// through stable attributes and the account's own API, not a mobile
|
||||
// layout, so it runs in one project.
|
||||
"notification-dispatch-journey.spec.ts",
|
||||
// v1.39 (C2) — the setup-flow journeys each own one account and walk
|
||||
// v1.39 — the setup-flow journeys each own one account and walk
|
||||
// it from the welcome screen to the dashboard, resetting it first. A
|
||||
// second project would walk the same account from a second browser
|
||||
// and each would find the other's answers. The two sweeps set their
|
||||
|
||||
Generated
-150
@@ -56,9 +56,6 @@ importers:
|
||||
'@dnd-kit/utilities':
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2(react@19.3.0)
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.9.1
|
||||
version: 5.9.1(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(effect@3.20.0)(react-hook-form@7.90.0(@types/react@19.3.0)(react@19.3.0))(valibot@1.4.2(typescript@6.0.3))(zod@4.6.7)
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.32.1
|
||||
version: 1.32.1(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.6.7)
|
||||
@@ -125,9 +122,6 @@ importers:
|
||||
next:
|
||||
specifier: 16.3.8
|
||||
version: 16.3.8(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@26.6.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
|
||||
nodemailer:
|
||||
specifier: 10.1.0
|
||||
version: 10.1.0
|
||||
@@ -161,9 +155,6 @@ importers:
|
||||
react-dom:
|
||||
specifier: 19.3.0
|
||||
version: 19.3.0(react@19.3.0)
|
||||
react-hook-form:
|
||||
specifier: 7.90.0
|
||||
version: 7.90.0(@types/react@19.3.0)(react@19.3.0)
|
||||
recharts:
|
||||
specifier: ^3.10.1
|
||||
version: 3.10.1(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react-is@16.13.1)(react@19.3.0)(redux@5.0.1)
|
||||
@@ -240,9 +231,6 @@ importers:
|
||||
'@types/web-push':
|
||||
specifier: ^3.6.4
|
||||
version: 3.6.4
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.1.2
|
||||
version: 6.1.2(vite@8.1.5(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^5.0.4
|
||||
version: 5.0.4(vitest@5.0.4)
|
||||
@@ -683,84 +671,6 @@ packages:
|
||||
peerDependencies:
|
||||
hono: ^4.13.5
|
||||
|
||||
'@hookform/resolvers@5.9.1':
|
||||
resolution: {integrity: sha512-7b7vsbraJxKgjVSA1Nur9tLwj539WGJUBLA7QNvXnFoT2pM5Z7G+6rlukk4B2/QrTZy6huRtH6wKeESPKuIr6w==}
|
||||
peerDependencies:
|
||||
'@sinclair/typebox': '>=0.25.24'
|
||||
'@standard-schema/spec': ^1.0.0
|
||||
'@typeschema/main': '>=0.13.7'
|
||||
'@vinejs/vine': ^2.0.0 || ^3.0.0 || ^4.0.0
|
||||
ajv: ^8.12.0
|
||||
ajv-errors: ^3.0.0
|
||||
ajv-formats: ^2.1.1
|
||||
arktype: ^2.0.0
|
||||
ata-validator: ^1.2.0
|
||||
class-transformer: '>=0.4.0'
|
||||
class-validator: '>=0.12.0'
|
||||
computed-types: ^1.0.0
|
||||
effect: ^3.10.3
|
||||
fluentvalidation-ts: ^3.0.0
|
||||
fp-ts: ^2.7.0
|
||||
io-ts: ^2.0.0
|
||||
joi: ^17.0.0 || ^18.0.0
|
||||
nope-validator: '>=0.12.0'
|
||||
react-hook-form: ^7.55.0
|
||||
superstruct: '>=0.12.0'
|
||||
typanion: ^3.3.2
|
||||
valibot: ^1.4.2
|
||||
vest: '>=6.0.0'
|
||||
yup: ^1.0.0
|
||||
zod: ^3.25.0 || ^4.0.0
|
||||
peerDependenciesMeta:
|
||||
'@sinclair/typebox':
|
||||
optional: true
|
||||
'@standard-schema/spec':
|
||||
optional: true
|
||||
'@typeschema/main':
|
||||
optional: true
|
||||
'@vinejs/vine':
|
||||
optional: true
|
||||
ajv:
|
||||
optional: true
|
||||
ajv-errors:
|
||||
optional: true
|
||||
ajv-formats:
|
||||
optional: true
|
||||
arktype:
|
||||
optional: true
|
||||
ata-validator:
|
||||
optional: true
|
||||
class-transformer:
|
||||
optional: true
|
||||
class-validator:
|
||||
optional: true
|
||||
computed-types:
|
||||
optional: true
|
||||
effect:
|
||||
optional: true
|
||||
fluentvalidation-ts:
|
||||
optional: true
|
||||
fp-ts:
|
||||
optional: true
|
||||
io-ts:
|
||||
optional: true
|
||||
joi:
|
||||
optional: true
|
||||
nope-validator:
|
||||
optional: true
|
||||
superstruct:
|
||||
optional: true
|
||||
typanion:
|
||||
optional: true
|
||||
valibot:
|
||||
optional: true
|
||||
vest:
|
||||
optional: true
|
||||
yup:
|
||||
optional: true
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
@@ -3166,22 +3076,6 @@ packages:
|
||||
'@visx/vendor@4.0.0-alpha.0':
|
||||
resolution: {integrity: sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==}
|
||||
|
||||
'@vitejs/plugin-react@6.1.2':
|
||||
resolution: {integrity: sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
peerDependencies:
|
||||
'@rolldown/plugin-babel': ^0.1.7 || ^0.2.0
|
||||
babel-plugin-react-compiler: ^1.0.0
|
||||
oxc-transform-react: ^0.152.0
|
||||
vite: ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
'@rolldown/plugin-babel':
|
||||
optional: true
|
||||
babel-plugin-react-compiler:
|
||||
optional: true
|
||||
oxc-transform-react:
|
||||
optional: true
|
||||
|
||||
'@vitest/coverage-v8@5.0.4':
|
||||
resolution: {integrity: sha512-P5YcQY8Y6RmoE9xRXI38rT8pssbcubEzqtYTM2XQoxOXMDn04nRaTI1SM8qCl9/NfW3ag9GlzdSXu7IwO5Ft8A==}
|
||||
peerDependencies:
|
||||
@@ -5305,12 +5199,6 @@ packages:
|
||||
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
next-themes@0.4.6:
|
||||
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||
|
||||
next@16.3.8:
|
||||
resolution: {integrity: sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
@@ -5880,16 +5768,6 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^19.3.0
|
||||
|
||||
react-hook-form@7.90.0:
|
||||
resolution: {integrity: sha512-+NdG0FLzCPhFOp2NNxaUYLF9fyZhNjx9FTBBSYWF71/l6BBlmIc/j6xlp3DbaHWLJbIG7Zd8oznA19xJClU97w==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8.0 || ^17 || ^18 || ^19
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
|
||||
@@ -7508,18 +7386,6 @@ snapshots:
|
||||
dependencies:
|
||||
hono: 4.13.7
|
||||
|
||||
'@hookform/resolvers@5.9.1(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(effect@3.20.0)(react-hook-form@7.90.0(@types/react@19.3.0)(react@19.3.0))(valibot@1.4.2(typescript@6.0.3))(zod@4.6.7)':
|
||||
dependencies:
|
||||
'@standard-schema/utils': 0.3.0
|
||||
react-hook-form: 7.90.0(@types/react@19.3.0)(react@19.3.0)
|
||||
optionalDependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
ajv: 8.20.0
|
||||
ajv-formats: 2.1.1(ajv@8.20.0)
|
||||
effect: 3.20.0
|
||||
valibot: 1.4.2(typescript@6.0.3)
|
||||
zod: 4.6.7
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
dependencies:
|
||||
'@humanfs/types': 0.15.0
|
||||
@@ -9848,11 +9714,6 @@ snapshots:
|
||||
d3-time-format: 4.1.0
|
||||
internmap: 2.0.3
|
||||
|
||||
'@vitejs/plugin-react@6.1.2(vite@8.1.5(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.1.5(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)
|
||||
|
||||
'@vitest/coverage-v8@5.0.4(vitest@5.0.4)':
|
||||
dependencies:
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
@@ -12056,11 +11917,6 @@ snapshots:
|
||||
|
||||
negotiator@1.0.0: {}
|
||||
|
||||
next-themes@0.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
|
||||
dependencies:
|
||||
react: 19.3.0
|
||||
react-dom: 19.3.0(react@19.3.0)
|
||||
|
||||
next@16.3.8(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.64.0)(@types/node@26.6.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
|
||||
dependencies:
|
||||
'@next/env': 16.3.8
|
||||
@@ -12676,12 +12532,6 @@ snapshots:
|
||||
react: 19.3.0
|
||||
scheduler: 0.28.0
|
||||
|
||||
react-hook-form@7.90.0(@types/react@19.3.0)(react@19.3.0):
|
||||
dependencies:
|
||||
react: 19.3.0
|
||||
optionalDependencies:
|
||||
'@types/react': 19.3.0
|
||||
|
||||
react-is@16.13.1: {}
|
||||
|
||||
react-redux@9.2.0(@types/react@19.3.0)(react@19.3.0)(redux@5.0.1):
|
||||
|
||||
+2
-3
@@ -78,9 +78,8 @@ overrides:
|
||||
# override is the only way through. It runs on the config-loading and CLI
|
||||
# path, not under a request, but a red audit blocks the whole release train.
|
||||
"deepmerge-ts@<8.0.0": "^8.0.0"
|
||||
# CVE-2026-59952. Two copies arrive, neither of them ours to call: an
|
||||
# optional peer of `@hookform/resolvers` (this app validates with zod) and
|
||||
# prisma's local dev server. Mirrored for /opt/prisma-cli in the Dockerfile,
|
||||
# CVE-2026-59952. The copy arrives through prisma's local dev server, not
|
||||
# ours to call. Mirrored for /opt/prisma-cli in the Dockerfile,
|
||||
# where the flagged 1.2.0 landed. 1.4.2 is the same major, so no consumer is
|
||||
# dragged onto a new one.
|
||||
"valibot@<1.4.2": "^1.4.2"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
-- The cached AI insights payload moves to AES-256-GCM at rest.
|
||||
--
|
||||
-- `users.insights_cached_text` held the model's last full reading of the
|
||||
-- account in the clear: the daily briefing, the per-domain status text and the
|
||||
-- recommendations. It was the last readable column of that kind on the user
|
||||
-- row. From this release every write goes to `insights_cached_text_encrypted`
|
||||
-- (binary layout, purpose-bound label) and leaves the readable column NULL.
|
||||
--
|
||||
-- SQL cannot encrypt, so this migration only adds the ciphertext column and
|
||||
-- the discovery index. The boot-time `free-text-encryption-backfill` job seals
|
||||
-- the existing rows one at a time and nulls the readable value in the same
|
||||
-- transaction; it is fail-closed, so a missing or malformed key leaves the row
|
||||
-- untouched and the job retries. Readers fall back to the readable column until
|
||||
-- the backfill has reached a row. The readable column is dropped in a later
|
||||
-- release, once the backfill reports nothing left on every instance.
|
||||
--
|
||||
-- Additive only: no table rewrite, no data change.
|
||||
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "insights_cached_text_encrypted" BYTEA;
|
||||
|
||||
-- Partial index on exactly the backfill's discovery predicate, so the boot
|
||||
-- scan is index-only and shrinks to nothing as the backfill converges (the
|
||||
-- 0357 precedent).
|
||||
CREATE INDEX IF NOT EXISTS "users_insights_cached_text_backfill_idx"
|
||||
ON "users" ("id")
|
||||
WHERE "insights_cached_text" IS NOT NULL;
|
||||
@@ -0,0 +1,75 @@
|
||||
-- Let an update of `measurements` that changes nothing stay a HOT update.
|
||||
--
|
||||
-- Every write path that re-reads a provider window (Withings, Fitbit, Oura,
|
||||
-- the Apple Health `stats:` overwrites, the reconcile paths) updates rows
|
||||
-- whose values did not change, and each such update moved `updated_at`
|
||||
-- (Prisma's `@updatedAt`) and `sync_version`. `updated_at` sits in the
|
||||
-- sync-feed index `measurements_user_id_updated_at_id_idx`, so not one of
|
||||
-- those updates could be heap-only: every one wrote a new row version on
|
||||
-- another page and an entry in every index, the table grew several times
|
||||
-- its live size, and every unchanged row went back down the `/api/sync/changes`
|
||||
-- feed to the phone.
|
||||
--
|
||||
-- 1. A row trigger keeps `updated_at` and `sync_version` as they were when
|
||||
-- the update changes no other column. Such an update then touches no
|
||||
-- indexed column, so Postgres can keep it on the page (HOT), and the
|
||||
-- sync feed stays silent because nothing changed. An update that does
|
||||
-- change something still moves both stamps, exactly as before.
|
||||
-- 2. `fillfactor = 85` leaves room on each page for those in-page versions;
|
||||
-- it applies to pages written from now on (a `VACUUM FULL` rewrites the
|
||||
-- rest).
|
||||
-- 3. Autovacuum starts at 5 % dead rows instead of 20 %, so a large table
|
||||
-- does not wait for a fifth of itself to be dead before it is cleaned.
|
||||
--
|
||||
-- `to_jsonb(row)` compares every column, so a column added later is covered
|
||||
-- without touching this function.
|
||||
--
|
||||
-- Lock wait. `CREATE TRIGGER` takes SHARE ROW EXCLUSIVE on the table and the
|
||||
-- `ALTER TABLE ... SET` SHARE UPDATE EXCLUSIVE; both wait for an autovacuum
|
||||
-- that is preventing wraparound, which does not step aside, and while they
|
||||
-- wait every write to `measurements` queues behind them, including the ones
|
||||
-- the running release still serves during a deploy. The wait is bounded at
|
||||
-- ten seconds; past that the attempt fails with SQLSTATE 55P03 and the queued
|
||||
-- writes go on.
|
||||
--
|
||||
-- `migrate deploy` does not run a migration file inside one transaction: each
|
||||
-- statement commits on its own, so a top-level `SET LOCAL` does nothing and a
|
||||
-- file that fails half-way stays half-applied. The two statements that take a
|
||||
-- lock therefore run inside one `DO` block, which is a single statement and
|
||||
-- so commits or rolls back whole, and the timeout is set inside it with
|
||||
-- `set_config(..., true)`, scoped to that statement. The function before it
|
||||
-- is `CREATE OR REPLACE` and harmless to run again. A lock timeout leaves the
|
||||
-- file safe to apply once more, which is what the marker below declares:
|
||||
-- `scripts/migrate-deploy.mjs` marks the attempt rolled back and retries it
|
||||
-- with a backoff (docs/ops/deploy.md, "A migration that timed out on a lock").
|
||||
--
|
||||
-- @retry-on-lock-timeout
|
||||
|
||||
CREATE OR REPLACE FUNCTION "measurements_keep_stamps_on_noop"() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF (to_jsonb(NEW) - 'updated_at' - 'sync_version')
|
||||
= (to_jsonb(OLD) - 'updated_at' - 'sync_version') THEN
|
||||
NEW."updated_at" := OLD."updated_at";
|
||||
NEW."sync_version" := OLD."sync_version";
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DO $migration$
|
||||
BEGIN
|
||||
PERFORM set_config('lock_timeout', '10s', true);
|
||||
|
||||
CREATE TRIGGER "measurements_noop_update_keeps_stamps"
|
||||
BEFORE UPDATE ON "measurements"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION "measurements_keep_stamps_on_noop"();
|
||||
|
||||
ALTER TABLE "measurements" SET (
|
||||
fillfactor = 85,
|
||||
autovacuum_vacuum_scale_factor = 0.05,
|
||||
autovacuum_analyze_scale_factor = 0.05
|
||||
);
|
||||
END
|
||||
$migration$;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Where an unfinished Strava backfill picks up.
|
||||
--
|
||||
-- One backfill run reads at most forty pages of a hundred activities. A
|
||||
-- longer history ended the run as incomplete without a note of how far it
|
||||
-- got, so every boot read the same newest forty pages again, up to 190 of
|
||||
-- the 200 requests the whole instance may make in fifteen minutes, and the
|
||||
-- older history was never reached. The column holds the start of the oldest
|
||||
-- activity the last run read; the next run asks for activities before it.
|
||||
-- Nullable and unset for every existing row: an unfinished backfill starts
|
||||
-- once more from the newest page and records its place from then on.
|
||||
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "strava_backfill_resume_before" TIMESTAMP(3);
|
||||
+22
-1
@@ -136,7 +136,18 @@ model User {
|
||||
useCentralCodex Boolean @default(false) @map("use_central_codex")
|
||||
insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") // "aggregated" | "raw"
|
||||
insightsCachedAt DateTime? @map("insights_cached_at")
|
||||
insightsCachedText String? @map("insights_cached_text") // cached JSON output
|
||||
/// @internal: legacy plaintext cache of the AI insights payload, read only
|
||||
/// as a fallback for rows the free-text encryption backfill has not reached
|
||||
/// yet (v1.43.7). Every write since then goes to `insightsCachedTextEncrypted`
|
||||
/// and leaves this NULL; the backfill seals and nulls it on the old rows.
|
||||
/// DROP this column in a later release, once the backfill reports zero
|
||||
/// remaining rows on every instance.
|
||||
insightsCachedText String? @map("insights_cached_text")
|
||||
/// v1.43.7 — the cached AI insights payload (briefing, per-domain readings,
|
||||
/// recommendations; JSON), AES-256-GCM binary layout under
|
||||
/// `INSIGHTS_CACHED_TEXT_AAD`. Read and written only through
|
||||
/// `src/lib/insights/cached-text-cipher.ts`.
|
||||
insightsCachedTextEncrypted Bytes? @map("insights_cached_text_encrypted")
|
||||
// Language the cached text was generated in. The cache is one slot per
|
||||
// user, so a reader in another language must not be served it: the
|
||||
// snapshot briefing lift and the advisor read treat a tag that differs
|
||||
@@ -305,6 +316,11 @@ model User {
|
||||
// First-connect full-history backfill marker (self-converging discovery
|
||||
// predicate `strava_backfill_completed_at IS NULL`), mirroring WHOOP/Fitbit.
|
||||
stravaBackfillCompletedAt DateTime? @map("strava_backfill_completed_at")
|
||||
// Where an unfinished backfill picks up: the start of the oldest activity
|
||||
// the last run read. A history longer than one run's page ceiling is read
|
||||
// in runs that each walk further back from here, instead of every run
|
||||
// starting again at the newest page. Cleared when the backfill completes.
|
||||
stravaBackfillResumeBefore DateTime? @map("strava_backfill_resume_before")
|
||||
|
||||
// Locale (null = use system/admin default)
|
||||
locale String? @map("locale") // "de" | "en" | null
|
||||
@@ -2094,6 +2110,11 @@ model Measurement {
|
||||
// limit+1`. Without this index Postgres filters by `userId` then sorts
|
||||
// the user's entire measurement set per page; the delta drain allows
|
||||
// 120 pulls/min/user. See migration 0096.
|
||||
// Because `updatedAt` is indexed here, an update that moves it can never
|
||||
// be heap-only. The trigger `measurements_noop_update_keeps_stamps`
|
||||
// (migration 0412) keeps `updatedAt` and `syncVersion` on an update that
|
||||
// changes nothing else, so those stay HOT; the table also carries
|
||||
// `fillfactor = 85` and a 5 % autovacuum threshold from the same migration.
|
||||
@@index([userId, updatedAt, id])
|
||||
// `measurements_legacy_step_consolidation_idx` is a partial index
|
||||
// over `type = 'ACTIVITY_STEPS' AND deleted_at IS NULL` — see
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@
|
||||
// and triggers an SW-unregister + cache-wipe + hard reload when the
|
||||
// server moves ahead of the running shell.
|
||||
//
|
||||
// v1.4.43 QoL (L3) — read the active version from `self.__APP_VERSION__`,
|
||||
// v1.4.43 — read the active version from `self.__APP_VERSION__`,
|
||||
// which the build step writes to `/public/sw-version.js` (loaded
|
||||
// synchronously below via `importScripts`). Pre-fix the literal was
|
||||
// hand-bumped per release and quietly drifted four releases stale, so
|
||||
@@ -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.43.6";
|
||||
/* @sw-version-fallback */ "v1.43.7";
|
||||
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,5 +1,5 @@
|
||||
/**
|
||||
* v1.4.42 W6 — env-check sanity tests.
|
||||
* v1.4.42 — env-check sanity tests.
|
||||
*
|
||||
* The script's two pure helpers (`parseEnvFile` + `checkEnv`) are
|
||||
* pinned here. The renderResults / main() entrypoint is intentionally
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* v1.4.43 QoL (L3) — verify the SW build step + the SW source contract.
|
||||
* v1.4.43 — verify the SW build step + the SW source contract.
|
||||
*
|
||||
* Pre-fix `public/sw.js` hard-coded `const CACHE_VERSION = "v1.4.38.4"`
|
||||
* which drifted four releases stale. The current shape:
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* The boot's `migrate deploy` wrapper (`scripts/migrate-deploy.mjs`): which
|
||||
* failures it retries, and the loop it runs around Prisma.
|
||||
*
|
||||
* The report texts below are what Prisma 7's `migrate deploy` printed against
|
||||
* a real Postgres 16 for a migration that hit its `lock_timeout` (P3018), and
|
||||
* for the boot after that (P3009). The loop is driven end to end through a
|
||||
* stand-in CLI that plays those reports back, so no database is needed here;
|
||||
* `tests/integration/migrate-deploy-lock-retry.test.ts` runs the real CLI.
|
||||
*/
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
decideLockRetry,
|
||||
lockTimeoutInReport,
|
||||
retryDelays,
|
||||
} from "../migrate-deploy.mjs";
|
||||
|
||||
const P3018_LOCK = `Applying migration \`0412_measurement_hot_updates\`
|
||||
Error: P3018
|
||||
|
||||
A migration failed to apply. New migrations cannot be applied before the error is recovered from. Read more about how to resolve migration issues in a production database: https://pris.ly/d/migrate-resolve
|
||||
|
||||
Migration name: 0412_measurement_hot_updates
|
||||
|
||||
Database error code: 55P03
|
||||
|
||||
Database error:
|
||||
ERROR: canceling statement due to lock timeout
|
||||
`;
|
||||
|
||||
const P3018_UNIQUE = P3018_LOCK.replace("55P03", "23505").replace(
|
||||
"canceling statement due to lock timeout",
|
||||
'duplicate key value violates unique constraint "x"',
|
||||
);
|
||||
|
||||
const P3009 = `Error: P3009
|
||||
|
||||
migrate found failed migrations in the target database, new migrations will not be applied. Read more about how to resolve migration issues in a production database: https://pris.ly/d/migrate-resolve
|
||||
The \`0412_measurement_hot_updates\` migration started at 2026-10-20 08:38:22.114155 UTC failed
|
||||
`;
|
||||
|
||||
const MARKED = "-- @retry-on-lock-timeout\nDO $$ BEGIN END $$;\n";
|
||||
const UNMARKED = "ALTER TABLE x ADD COLUMN y int;\n";
|
||||
|
||||
const reader =
|
||||
(sql: string | null) =>
|
||||
(_name: string): string | null =>
|
||||
sql;
|
||||
|
||||
describe("which failure is retried", () => {
|
||||
it("reads the failed migration and its SQLSTATE from Prisma's report", () => {
|
||||
expect(lockTimeoutInReport(P3018_LOCK)).toBe(
|
||||
"0412_measurement_hot_updates",
|
||||
);
|
||||
expect(lockTimeoutInReport(P3018_UNIQUE)).toBeNull();
|
||||
expect(lockTimeoutInReport(P3009)).toBeNull();
|
||||
expect(lockTimeoutInReport(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("retries a lock timeout of a migration marked safe to re-run", () => {
|
||||
expect(
|
||||
decideLockRetry({
|
||||
output: P3018_LOCK,
|
||||
failedRows: null,
|
||||
readMigration: reader(MARKED),
|
||||
}),
|
||||
).toEqual({ retry: true, migration: "0412_measurement_hot_updates" });
|
||||
});
|
||||
|
||||
it("does not retry a lock timeout of an unmarked migration", () => {
|
||||
const decision = decideLockRetry({
|
||||
output: P3018_LOCK,
|
||||
failedRows: null,
|
||||
readMigration: reader(UNMARKED),
|
||||
});
|
||||
expect(decision.retry).toBe(false);
|
||||
expect(decision).toMatchObject({
|
||||
reason: expect.stringContaining("not marked safe to re-run"),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not retry any other database error", () => {
|
||||
expect(
|
||||
decideLockRetry({
|
||||
output: P3018_UNIQUE,
|
||||
failedRows: null,
|
||||
readMigration: reader(MARKED),
|
||||
}),
|
||||
).toEqual({ retry: false, reason: "not a lock timeout" });
|
||||
});
|
||||
|
||||
it("does not retry a migration the image does not ship", () => {
|
||||
expect(
|
||||
decideLockRetry({
|
||||
output: P3018_LOCK,
|
||||
failedRows: null,
|
||||
readMigration: reader(null),
|
||||
}).retry,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("on the boot after a failure, reads the cause from the stored row", () => {
|
||||
const failedRows = [
|
||||
{ migration_name: "0412_measurement_hot_updates", logs: P3018_LOCK },
|
||||
];
|
||||
expect(
|
||||
decideLockRetry({
|
||||
output: P3009,
|
||||
failedRows,
|
||||
readMigration: reader(MARKED),
|
||||
}),
|
||||
).toEqual({ retry: true, migration: "0412_measurement_hot_updates" });
|
||||
// A stored failure of another kind stays fatal.
|
||||
expect(
|
||||
decideLockRetry({
|
||||
output: P3009,
|
||||
failedRows: [{ ...failedRows[0], logs: P3018_UNIQUE }],
|
||||
readMigration: reader(MARKED),
|
||||
}).retry,
|
||||
).toBe(false);
|
||||
// Two failed rows, or rows that could not be read: not a state to guess at.
|
||||
expect(
|
||||
decideLockRetry({
|
||||
output: P3009,
|
||||
failedRows: [failedRows[0], failedRows[0]],
|
||||
readMigration: reader(MARKED),
|
||||
}).retry,
|
||||
).toBe(false);
|
||||
expect(
|
||||
decideLockRetry({
|
||||
output: P3009,
|
||||
failedRows: null,
|
||||
readMigration: reader(MARKED),
|
||||
}).retry,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a migration name that is not a directory name", () => {
|
||||
expect(
|
||||
decideLockRetry({
|
||||
output: P3018_LOCK.replace(
|
||||
"Migration name: 0412_measurement_hot_updates",
|
||||
"Migration name: ../../etc",
|
||||
),
|
||||
failedRows: null,
|
||||
readMigration: reader(MARKED),
|
||||
}).retry,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("reads the backoff from the environment and falls back on nonsense", () => {
|
||||
expect(retryDelays(undefined)).toEqual([15, 30, 60, 120, 240]);
|
||||
expect(retryDelays("0, 1")).toEqual([0, 1]);
|
||||
expect(retryDelays("a,1")).toEqual([15, 30, 60, 120, 240]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the retry loop", () => {
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* A stand-in Prisma CLI: each `migrate deploy` prints the next scripted
|
||||
* report and exits with its code; every call is appended to `calls.log`.
|
||||
*/
|
||||
function setup(reports: Array<[number, string]>, sql: string) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hl-migrate-deploy-"));
|
||||
dirs.push(dir);
|
||||
const migrations = join(dir, "migrations");
|
||||
mkdirSync(join(migrations, "0412_measurement_hot_updates"), {
|
||||
recursive: true,
|
||||
});
|
||||
writeFileSync(
|
||||
join(migrations, "0412_measurement_hot_updates", "migration.sql"),
|
||||
sql,
|
||||
);
|
||||
writeFileSync(join(dir, "reports.json"), JSON.stringify(reports));
|
||||
const cli = join(dir, "fake-prisma.mjs");
|
||||
writeFileSync(
|
||||
cli,
|
||||
`import { appendFileSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
const dir = ${JSON.stringify(dir)};
|
||||
const args = process.argv.slice(2);
|
||||
appendFileSync(dir + "/calls.log", args.join(" ") + "\\n");
|
||||
if (args[0] === "migrate" && args[1] === "deploy") {
|
||||
const n = existsSync(dir + "/n") ? Number(readFileSync(dir + "/n", "utf8")) : 0;
|
||||
writeFileSync(dir + "/n", String(n + 1));
|
||||
const [code, text] = JSON.parse(readFileSync(dir + "/reports.json", "utf8"))[n];
|
||||
process.stderr.write(text);
|
||||
process.exit(code);
|
||||
}
|
||||
process.exit(0);
|
||||
`,
|
||||
);
|
||||
const run = () =>
|
||||
spawnSync(
|
||||
process.execPath,
|
||||
[join(process.cwd(), "scripts/migrate-deploy.mjs"), "--schema", "x"],
|
||||
{
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
DATABASE_URL: "",
|
||||
HEALTHLOG_PRISMA_CLI: cli,
|
||||
HEALTHLOG_MIGRATIONS_DIR: migrations,
|
||||
HEALTHLOG_MIGRATE_LOCK_RETRY_DELAYS: "0,0",
|
||||
},
|
||||
},
|
||||
);
|
||||
const calls = () =>
|
||||
existsSync(join(dir, "calls.log"))
|
||||
? readFileSync(join(dir, "calls.log"), "utf8").trim().split("\n")
|
||||
: [];
|
||||
return { run, calls };
|
||||
}
|
||||
|
||||
it("marks the timed-out migration rolled back and deploys again", () => {
|
||||
const { run, calls } = setup(
|
||||
[
|
||||
[1, P3018_LOCK],
|
||||
[0, "All migrations have been successfully applied."],
|
||||
],
|
||||
MARKED,
|
||||
);
|
||||
const result = run();
|
||||
expect(result.status).toBe(0);
|
||||
expect(calls()).toEqual([
|
||||
"migrate deploy --schema x",
|
||||
"migrate resolve --rolled-back 0412_measurement_hot_updates --schema x",
|
||||
"migrate deploy --schema x",
|
||||
]);
|
||||
expect(result.stdout).toContain("timed out waiting for a lock (55P03)");
|
||||
});
|
||||
|
||||
it("gives up after the last delay with Prisma's exit code", () => {
|
||||
const { run, calls } = setup(
|
||||
[
|
||||
[1, P3018_LOCK],
|
||||
[1, P3018_LOCK],
|
||||
[1, P3018_LOCK],
|
||||
],
|
||||
MARKED,
|
||||
);
|
||||
const result = run();
|
||||
expect(result.status).toBe(1);
|
||||
expect(calls().filter((c) => c.startsWith("migrate deploy"))).toHaveLength(
|
||||
3,
|
||||
);
|
||||
expect(calls().filter((c) => c.startsWith("migrate resolve"))).toHaveLength(
|
||||
2,
|
||||
);
|
||||
expect(result.stderr).toContain("giving up");
|
||||
});
|
||||
|
||||
it("stops at the first failure that is not a lock timeout", () => {
|
||||
const { run, calls } = setup([[1, P3018_UNIQUE]], MARKED);
|
||||
const result = run();
|
||||
expect(result.status).toBe(1);
|
||||
expect(calls()).toEqual(["migrate deploy --schema x"]);
|
||||
});
|
||||
|
||||
it("stops at a lock timeout of a migration that is not marked", () => {
|
||||
const { run, calls } = setup([[1, P3018_LOCK]], UNMARKED);
|
||||
const result = run();
|
||||
expect(result.status).toBe(1);
|
||||
expect(calls()).toEqual(["migrate deploy --schema x"]);
|
||||
expect(result.stderr).toContain("not marked safe to re-run");
|
||||
});
|
||||
});
|
||||
@@ -144,6 +144,7 @@ let totalGz = 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)) {
|
||||
if (!f.endsWith(".js")) continue;
|
||||
@@ -153,6 +154,7 @@ for (const f of readdirSync(chunkDir)) {
|
||||
if (info.catalog) {
|
||||
catalogChunks += 1;
|
||||
catalogGz += info.gz;
|
||||
if (info.gz > largestCatalog.gz) largestCatalog = { file: f, gz: info.gz };
|
||||
}
|
||||
if (info.gz > largest.gz) largest = { file: f, gz: info.gz };
|
||||
}
|
||||
@@ -169,6 +171,9 @@ 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})`,
|
||||
);
|
||||
console.log(
|
||||
`largest chunk: ${fmt(largest.gz)} gz ${largest.file}`,
|
||||
);
|
||||
@@ -235,9 +240,19 @@ for (const [route, baselineKb] of Object.entries(baselines)) {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (budget.totalClientKbGz && kb(totalGz) > budget.totalClientKbGz) {
|
||||
// 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) {
|
||||
failures.push(
|
||||
`total client JS ${fmt(totalGz)} gz exceeds the ${budget.totalClientKbGz} KB budget`,
|
||||
`client JS without the lazy catalogs ${fmt(nonCatalogGz)} gz exceeds the ${budget.totalClientKbGz} KB budget`,
|
||||
);
|
||||
}
|
||||
if (budget.maxCatalogKbGz && kb(largestCatalog.gz) > budget.maxCatalogKbGz) {
|
||||
failures.push(
|
||||
`message catalog ${largestCatalog.file} ${fmt(largestCatalog.gz)} gz exceeds the ${budget.maxCatalogKbGz} KB per-catalog budget`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -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"];
|
||||
const LOCALES = ["de", "en", "fr", "es", "it", "pl", "ko", "nl"];
|
||||
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* v1.4.43 QoL (L3) — write the active package.json version into
|
||||
* v1.4.43 — write the active package.json version into
|
||||
* `public/sw-version.js`. The Service Worker (`public/sw.js`) loads
|
||||
* this file via `importScripts('/sw-version.js')` and reads
|
||||
* `self.__APP_VERSION__` to key its `CACHE_VERSION`.
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"];
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"];
|
||||
const file = (loc) => `messages/${loc}.json`;
|
||||
|
||||
function read(loc) {
|
||||
|
||||
@@ -329,7 +329,8 @@ readonly USERS_COLS="id, username, email, password_hash, role, created_at, updat
|
||||
height_cm, date_of_birth, gender, timezone,
|
||||
codex_access_token_encrypted, codex_refresh_token_encrypted, codex_token_expires_at,
|
||||
codex_connected_at, codex_connection_status,
|
||||
insights_privacy_mode, insights_cached_at, insights_cached_text, insights_exclude_metrics,
|
||||
insights_privacy_mode, insights_cached_at, insights_cached_text, insights_cached_text_encrypted,
|
||||
insights_exclude_metrics,
|
||||
global_excluded_injection_sites,
|
||||
telegram_bot_token, telegram_chat_id, telegram_enabled,
|
||||
withings_client_id_encrypted, withings_client_secret_encrypted,
|
||||
@@ -361,7 +362,8 @@ readonly USERS_SELECT="id, username, email, password_hash,
|
||||
NULL::timestamptz AS codex_token_expires_at,
|
||||
NULL::timestamptz AS codex_connected_at,
|
||||
'disconnected' AS codex_connection_status,
|
||||
insights_privacy_mode, insights_cached_at, insights_cached_text, insights_exclude_metrics,
|
||||
insights_privacy_mode, insights_cached_at, insights_cached_text, insights_cached_text_encrypted,
|
||||
insights_exclude_metrics,
|
||||
global_excluded_injection_sites,
|
||||
NULL::text AS telegram_bot_token,
|
||||
NULL::text AS telegram_chat_id,
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* `prisma migrate deploy` for the container boot, with one narrow retry: a
|
||||
* migration that gave up waiting for a lock.
|
||||
*
|
||||
* Run by `docker-entrypoint.sh` as `node scripts/migrate-deploy.mjs`.
|
||||
*
|
||||
* A migration that bounds its lock wait (`lock_timeout`) fails with SQLSTATE
|
||||
* 55P03 when something holds the table longer, typically an autovacuum that
|
||||
* is preventing wraparound and does not step aside. Prisma then records the
|
||||
* attempt as failed, and every later `migrate deploy` stops at P3009 until an
|
||||
* operator runs `prisma migrate resolve --rolled-back`: under a restart
|
||||
* policy that is a container that never comes up.
|
||||
*
|
||||
* `migrate deploy` does not wrap a migration file in a transaction, so a
|
||||
* failed file is not necessarily rolled back: statements before the one that
|
||||
* failed stay applied. Marking such an attempt rolled back and running it
|
||||
* again is only safe when the file was written for it. A migration declares
|
||||
* that with a `-- @retry-on-lock-timeout` line, which its author adds only
|
||||
* when every statement before the lock-taking one is harmless to repeat and
|
||||
* the lock-taking statements form one statement (a `DO` block) that rolls
|
||||
* back whole. `src/__tests__/migration-lock-timeout-guard.test.ts` holds the
|
||||
* migrations to that.
|
||||
*
|
||||
* So: on a 55P03 failure of a migration carrying the marker, mark that one
|
||||
* migration rolled back and run `migrate deploy` again after a backoff (15,
|
||||
* 30, 60, 120, 240 seconds). Any other failure, a lock timeout of a migration
|
||||
* without the marker, or a fifth lock timeout in a row, ends the boot with
|
||||
* Prisma's exit code exactly as before. The failure is read from Prisma's
|
||||
* own report (`Migration name:` and `Database error code:`), or, on a boot
|
||||
* after a failed one (P3009), from the `logs` Prisma stored on the failed
|
||||
* row in `_prisma_migrations`.
|
||||
*
|
||||
* Environment:
|
||||
* HEALTHLOG_PRISMA_CLI Prisma's CLI entry (default: the image's copy)
|
||||
* HEALTHLOG_MIGRATIONS_DIR where the migration files are (default:
|
||||
* prisma/migrations under the working directory)
|
||||
* HEALTHLOG_MIGRATE_LOCK_RETRY_DELAYS
|
||||
* comma-separated backoff in seconds
|
||||
* Arguments are passed through to every Prisma call (`--schema …`).
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const DEFAULT_RETRY_DELAYS_S = [15, 30, 60, 120, 240];
|
||||
|
||||
/** The line a migration carries when a lock-timeout retry is safe for it. */
|
||||
export const RETRY_MARKER = /^--\s*@retry-on-lock-timeout\s*$/m;
|
||||
|
||||
const LOCK_TIMEOUT = "55P03";
|
||||
const MIGRATION_NAME = /^[A-Za-z0-9_]+$/;
|
||||
|
||||
/**
|
||||
* The migration a P3018 report says failed on a lock timeout, or null.
|
||||
* Prisma prints `Migration name: <name>` and `Database error code: <state>`
|
||||
* for a migration that failed to apply.
|
||||
*/
|
||||
export function lockTimeoutInReport(text) {
|
||||
if (typeof text !== "string") return null;
|
||||
const name = /^Migration name: (\S+)\s*$/m.exec(text)?.[1];
|
||||
const code = /^Database error code: (\S+)\s*$/m.exec(text)?.[1];
|
||||
return name && code === LOCK_TIMEOUT ? name : null;
|
||||
}
|
||||
|
||||
/** Whether Prisma refused to start because a failed migration is on record. */
|
||||
function isFailedMigrationOnRecord(output) {
|
||||
return /\bP3009\b/.test(output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a failed `migrate deploy` is the one failure retried here.
|
||||
*
|
||||
* @param {object} input
|
||||
* @param {string} input.output everything Prisma printed
|
||||
* @param {Array<{migration_name: string, logs: string | null}> | null} input.failedRows
|
||||
* the unfinished, not-rolled-back rows of `_prisma_migrations`, or null
|
||||
* when they were not read
|
||||
* @param {(name: string) => string | null} input.readMigration
|
||||
* the migration's SQL, or null when the image does not ship it
|
||||
* @returns {{ retry: true, migration: string } | { retry: false, reason: string }}
|
||||
*/
|
||||
export function decideLockRetry({ output, failedRows, readMigration }) {
|
||||
let migration = lockTimeoutInReport(output);
|
||||
if (!migration && isFailedMigrationOnRecord(output) && failedRows) {
|
||||
// More than one failed row is not a state this boot can reason about.
|
||||
if (failedRows.length === 1) {
|
||||
migration = lockTimeoutInReport(failedRows[0].logs);
|
||||
}
|
||||
}
|
||||
if (!migration) {
|
||||
return { retry: false, reason: "not a lock timeout" };
|
||||
}
|
||||
if (!MIGRATION_NAME.test(migration)) {
|
||||
return { retry: false, reason: `unexpected migration name ${migration}` };
|
||||
}
|
||||
const sql = readMigration(migration);
|
||||
if (sql === null) {
|
||||
return { retry: false, reason: `${migration} is not in this image` };
|
||||
}
|
||||
if (!RETRY_MARKER.test(sql)) {
|
||||
return {
|
||||
retry: false,
|
||||
reason: `${migration} timed out on a lock but is not marked safe to re-run`,
|
||||
};
|
||||
}
|
||||
return { retry: true, migration };
|
||||
}
|
||||
|
||||
/** The backoff schedule, from the environment or the default. */
|
||||
export function retryDelays(raw) {
|
||||
if (!raw) return DEFAULT_RETRY_DELAYS_S;
|
||||
const delays = raw.split(",").map((part) => Number(part.trim()));
|
||||
return delays.every((d) => Number.isFinite(d) && d >= 0)
|
||||
? delays
|
||||
: DEFAULT_RETRY_DELAYS_S;
|
||||
}
|
||||
|
||||
function runPrisma(cli, args) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [cli, ...args], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let output = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
output += chunk;
|
||||
process.stdout.write(chunk);
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
output += chunk;
|
||||
process.stderr.write(chunk);
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
output += String(error);
|
||||
resolve({ code: 1, output });
|
||||
});
|
||||
child.on("close", (code) => resolve({ code: code ?? 1, output }));
|
||||
});
|
||||
}
|
||||
|
||||
/** The failed rows of `_prisma_migrations`, or null when they cannot be read. */
|
||||
async function readFailedRows(connectionString) {
|
||||
if (!connectionString) return null;
|
||||
try {
|
||||
const { default: pg } = await import("pg");
|
||||
const url = new URL(connectionString);
|
||||
const schema = url.searchParams.get("schema") || "public";
|
||||
url.searchParams.delete("schema");
|
||||
const client = new pg.Client({
|
||||
connectionString: url.toString(),
|
||||
connectionTimeoutMillis: 5_000,
|
||||
statement_timeout: 5_000,
|
||||
});
|
||||
await client.connect();
|
||||
try {
|
||||
const { rows } = await client.query(
|
||||
`SELECT migration_name, logs FROM ${client.escapeIdentifier(schema)}._prisma_migrations
|
||||
WHERE finished_at IS NULL AND rolled_back_at IS NULL`,
|
||||
);
|
||||
return rows;
|
||||
} finally {
|
||||
await client.end().catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function migrationReader(dir) {
|
||||
return (name) => {
|
||||
try {
|
||||
return readFileSync(join(dir, name, "migration.sql"), "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function main({
|
||||
cli = process.env.HEALTHLOG_PRISMA_CLI ||
|
||||
"/opt/prisma-cli/node_modules/prisma/build/index.js",
|
||||
migrationsDir = process.env.HEALTHLOG_MIGRATIONS_DIR ||
|
||||
join(process.cwd(), "prisma", "migrations"),
|
||||
delays = retryDelays(process.env.HEALTHLOG_MIGRATE_LOCK_RETRY_DELAYS),
|
||||
extraArgs = process.argv.slice(2),
|
||||
} = {}) {
|
||||
const readMigration = migrationReader(migrationsDir);
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const deploy = await runPrisma(cli, ["migrate", "deploy", ...extraArgs]);
|
||||
if (deploy.code === 0) return 0;
|
||||
|
||||
const failedRows = isFailedMigrationOnRecord(deploy.output)
|
||||
? await readFailedRows(process.env.DATABASE_URL)
|
||||
: null;
|
||||
const decision = decideLockRetry({
|
||||
output: deploy.output,
|
||||
failedRows,
|
||||
readMigration,
|
||||
});
|
||||
if (!decision.retry) {
|
||||
if (decision.reason !== "not a lock timeout") {
|
||||
console.error(
|
||||
`HealthLog: not retrying the migration: ${decision.reason}.`,
|
||||
);
|
||||
}
|
||||
return deploy.code;
|
||||
}
|
||||
if (attempt >= delays.length) {
|
||||
console.error(
|
||||
`HealthLog: ERROR — migration ${decision.migration} timed out on a lock ` +
|
||||
`${attempt + 1} times in a row; giving up. Something holds the table ` +
|
||||
"for minutes: see docs/ops/deploy.md, " +
|
||||
'"A migration that timed out on a lock".',
|
||||
);
|
||||
return deploy.code;
|
||||
}
|
||||
const delay = delays[attempt];
|
||||
console.log(
|
||||
`HealthLog: migration ${decision.migration} timed out waiting for a lock ` +
|
||||
`(55P03) and changed nothing; marking it rolled back and retrying in ` +
|
||||
`${delay}s (retry ${attempt + 1} of ${delays.length}).`,
|
||||
);
|
||||
const resolve = await runPrisma(cli, [
|
||||
"migrate",
|
||||
"resolve",
|
||||
"--rolled-back",
|
||||
decision.migration,
|
||||
...extraArgs,
|
||||
]);
|
||||
if (resolve.code !== 0) return resolve.code;
|
||||
await sleep(delay * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
process.exitCode = await main();
|
||||
}
|
||||
@@ -863,6 +863,15 @@ async function main() {
|
||||
} as unknown as CorpusClient),
|
||||
);
|
||||
|
||||
// ───── v1.43.7 cached AI insights payload (Bytes, binary2, batched) ─────
|
||||
// The model's briefing and readings on the user row. NULL on a row without
|
||||
// a cache, or one the free-text backfill has not sealed yet; both skipped.
|
||||
results.push(
|
||||
await rotateRegistryColumn("User", "insightsCachedTextEncrypted", {
|
||||
user: prisma.user,
|
||||
} as unknown as CorpusClient),
|
||||
);
|
||||
|
||||
// ───── v1.39.3 custom-metric reading note (Bytes column) ─────
|
||||
// NULL on a reading without a note, and on a row the free-text backfill has
|
||||
// not reached yet; `rotateBytesColumn` skips both.
|
||||
|
||||
+10
-3
@@ -88,6 +88,7 @@ import {
|
||||
getKeyFingerprint,
|
||||
} from "../src/lib/crypto";
|
||||
import { ENVIRONMENT_LOCATION_AAD } from "../src/lib/crypto/encrypted-columns";
|
||||
import { sealInsightsCachedText } from "../src/lib/insights/cached-text-cipher";
|
||||
import {
|
||||
DEFAULT_DASHBOARD_LAYOUT,
|
||||
serializeDashboardLayout,
|
||||
@@ -3343,7 +3344,8 @@ async function seed() {
|
||||
const bakedProvider = "anthropic";
|
||||
|
||||
// ── Comprehensive insight + daily briefing ──
|
||||
// Stored on users.insights_cached_text (JSON). The GET read parses it and
|
||||
// Stored sealed on users.insights_cached_text_encrypted (JSON). The GET
|
||||
// read opens and parses it and
|
||||
// returns it as-is; setting insights_cached_at = NOW() keeps it inside the
|
||||
// 24 h freshness window for the dashboard-snapshot briefing path. The shape
|
||||
// is the union of insightResultSchema (summary / findings / correlations /
|
||||
@@ -3527,14 +3529,19 @@ async function seed() {
|
||||
|
||||
await client.query(
|
||||
`UPDATE users
|
||||
SET insights_cached_text = $2,
|
||||
SET insights_cached_text_encrypted = $2,
|
||||
insights_cached_text = NULL,
|
||||
insights_cached_at = NOW(),
|
||||
insights_snapshot_hash = $3,
|
||||
insights_briefing_reroll_date = $4
|
||||
WHERE id = $1`,
|
||||
[
|
||||
userId,
|
||||
JSON.stringify(comprehensiveInsight),
|
||||
// Sealed like every write since v1.43.7; the app never reads the
|
||||
// readable column while a ciphertext is there.
|
||||
Buffer.from(
|
||||
sealInsightsCachedText(JSON.stringify(comprehensiveInsight)),
|
||||
),
|
||||
// A stable fingerprint. The 24 h cache window keeps the row fresh on
|
||||
// its own; this value just has to be present + non-null so a
|
||||
// hypothetical regeneration would treat the snapshot as known. The
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"];
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"];
|
||||
const BOUNDARY = /(?<=[.!?])\s+(?=[A-ZÄÖÜÀÂÉÈÊÎÔÙÛÇŁŚŹŻĄĆĘŃÓ0-9„"„«])/u;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Remove planning-note identifiers from the head of comment lines.
|
||||
*
|
||||
* node scripts/strip-planning-tags.mjs # report only, exit 1 on hits
|
||||
* node scripts/strip-planning-tags.mjs --write # rewrite the files
|
||||
* node scripts/strip-planning-tags.mjs --write src/lib/foo.ts …
|
||||
*
|
||||
* What counts as a tag is defined once, in
|
||||
* `eslint-plugins/healthlog/planning-tags.js`; the `healthlog/no-planning-tags`
|
||||
* rule uses the same matcher, so a tree this script leaves clean is a tree
|
||||
* the rule accepts.
|
||||
*
|
||||
* Only comment text changes. Comments are located with the TypeScript parser
|
||||
* rather than a regex, so string and template contents are never touched, and
|
||||
* every rewritten file is re-parsed and printed with comments removed: if
|
||||
* that printout differs from the original's in any way, the file is left
|
||||
* alone and the run fails.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { extname } from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const ts = require("typescript");
|
||||
const {
|
||||
matchPlanningTag,
|
||||
commentLines,
|
||||
} = require("../eslint-plugins/healthlog/planning-tags.js");
|
||||
|
||||
const EXTENSIONS = new Set([
|
||||
".ts",
|
||||
".tsx",
|
||||
".mts",
|
||||
".cts",
|
||||
".js",
|
||||
".jsx",
|
||||
".mjs",
|
||||
".cjs",
|
||||
]);
|
||||
const EXCLUDED_PREFIXES = [
|
||||
"src/generated/",
|
||||
"eslint-plugins/",
|
||||
"node_modules/",
|
||||
];
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const write = args.includes("--write");
|
||||
const explicit = args.filter((a) => !a.startsWith("--"));
|
||||
|
||||
const files = (
|
||||
explicit.length > 0
|
||||
? explicit
|
||||
: execFileSync("git", ["ls-files"], { encoding: "utf8" }).split("\n")
|
||||
).filter(
|
||||
(f) =>
|
||||
f &&
|
||||
EXTENSIONS.has(extname(f)) &&
|
||||
!EXCLUDED_PREFIXES.some((p) => f.startsWith(p)),
|
||||
);
|
||||
|
||||
function scriptKind(file) {
|
||||
switch (extname(file)) {
|
||||
case ".tsx":
|
||||
return ts.ScriptKind.TSX;
|
||||
case ".jsx":
|
||||
return ts.ScriptKind.JSX;
|
||||
case ".js":
|
||||
case ".mjs":
|
||||
case ".cjs":
|
||||
return ts.ScriptKind.JS;
|
||||
default:
|
||||
return ts.ScriptKind.TS;
|
||||
}
|
||||
}
|
||||
|
||||
function parse(file, text) {
|
||||
return ts.createSourceFile(
|
||||
file,
|
||||
text,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
scriptKind(file),
|
||||
);
|
||||
}
|
||||
|
||||
/** Every comment range in the file, in source order, without duplicates. */
|
||||
function commentRanges(sf) {
|
||||
const text = sf.getFullText();
|
||||
const seen = new Map();
|
||||
const literalSpans = [];
|
||||
const add = (ranges) => {
|
||||
for (const r of ranges ?? []) seen.set(r.pos, r);
|
||||
};
|
||||
const visit = (node) => {
|
||||
add(ts.getLeadingCommentRanges(text, node.pos));
|
||||
add(ts.getTrailingCommentRanges(text, node.end));
|
||||
if (
|
||||
node.kind === ts.SyntaxKind.JsxText ||
|
||||
ts.isStringLiteralLike(node) ||
|
||||
node.kind === ts.SyntaxKind.TemplateHead ||
|
||||
node.kind === ts.SyntaxKind.TemplateMiddle ||
|
||||
node.kind === ts.SyntaxKind.TemplateTail ||
|
||||
node.kind === ts.SyntaxKind.RegularExpressionLiteral
|
||||
) {
|
||||
literalSpans.push([node.getStart(sf), node.end]);
|
||||
return;
|
||||
}
|
||||
for (const child of node.getChildren(sf)) visit(child);
|
||||
};
|
||||
visit(sf);
|
||||
add(ts.getLeadingCommentRanges(text, sf.endOfFileToken.pos));
|
||||
return [...seen.values()]
|
||||
.filter((r) => !literalSpans.some(([s, e]) => r.pos >= s && r.pos < e))
|
||||
.sort((a, b) => a.pos - b.pos);
|
||||
}
|
||||
|
||||
function printWithoutComments(sf) {
|
||||
return ts.createPrinter({ removeComments: true }).printFile(sf);
|
||||
}
|
||||
|
||||
function escapeRegExp(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
let filesChanged = 0;
|
||||
let linesChanged = 0;
|
||||
let tagsRemoved = 0;
|
||||
let failures = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const original = readFileSync(file, "utf8");
|
||||
const sf = parse(file, original);
|
||||
const ranges = commentRanges(sf);
|
||||
if (ranges.length === 0) continue;
|
||||
|
||||
let codeOnly = "";
|
||||
let cursor = 0;
|
||||
for (const r of ranges) {
|
||||
codeOnly += original.slice(cursor, r.pos) + " ".repeat(r.end - r.pos);
|
||||
cursor = r.end;
|
||||
}
|
||||
codeOnly += original.slice(cursor);
|
||||
const usedInCode = (tag) =>
|
||||
new RegExp(`(?<![A-Za-z0-9-])${escapeRegExp(tag)}(?![A-Za-z0-9-])`).test(
|
||||
codeOnly,
|
||||
);
|
||||
|
||||
const edits = [];
|
||||
for (const r of ranges) {
|
||||
const raw = original.slice(r.pos, r.end);
|
||||
for (const line of commentLines(raw)) {
|
||||
const hit = matchPlanningTag(line.text, usedInCode);
|
||||
if (!hit) continue;
|
||||
const start = r.pos + line.offset + line.prefix.length;
|
||||
edits.push({
|
||||
start,
|
||||
end: start + line.text.length,
|
||||
replacement: hit.replacement,
|
||||
});
|
||||
tagsRemoved += Math.max(hit.tags.length, 1);
|
||||
if (!write) console.log(`${file}\t${line.text}\t${hit.replacement}`);
|
||||
}
|
||||
}
|
||||
if (edits.length === 0) continue;
|
||||
|
||||
let next = original;
|
||||
for (const e of edits.sort((a, b) => b.start - a.start)) {
|
||||
next = next.slice(0, e.start) + e.replacement + next.slice(e.end);
|
||||
}
|
||||
|
||||
if (printWithoutComments(parse(file, next)) !== printWithoutComments(sf)) {
|
||||
console.error(
|
||||
`${file}: rewrite would change code outside comments; left alone`,
|
||||
);
|
||||
failures++;
|
||||
continue;
|
||||
}
|
||||
filesChanged++;
|
||||
linesChanged += edits.length;
|
||||
if (write) writeFileSync(file, next);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${write ? "rewrote" : "found"} ${linesChanged} comment lines (${tagsRemoved} tags) in ${filesChanged} files`,
|
||||
);
|
||||
if (failures > 0) process.exit(2);
|
||||
if (!write && linesChanged > 0) process.exit(1);
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* v1.4.39 W-SINCE — defense-in-depth row cap on the live-fallback
|
||||
* v1.4.39 — defense-in-depth row cap on the live-fallback
|
||||
* per-type read.
|
||||
*
|
||||
* The v1.4.38.7 perf audit (`.planning/round-v1438-perf-analysis.md`
|
||||
@@ -35,6 +35,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
// The empty recovery page reads the Oura fields for its reason.
|
||||
user: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -118,7 +120,7 @@ vi.mock("@/lib/analytics/correlations-fast-path", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/analytics/summaries-slice", () => ({
|
||||
computeSummariesSlice: vi.fn().mockResolvedValue({
|
||||
readSummariesSlice: vi.fn().mockResolvedValue({
|
||||
summaries: {},
|
||||
bmi: null,
|
||||
bpInTargetPct: null,
|
||||
@@ -176,7 +178,7 @@ beforeEach(async () => {
|
||||
await import("@/lib/analytics/score/reader");
|
||||
const { computeCorrelationHypothesesFastPath } =
|
||||
await import("@/lib/analytics/correlations-fast-path");
|
||||
const { computeSummariesSlice } =
|
||||
const { readSummariesSlice } =
|
||||
await import("@/lib/analytics/summaries-slice");
|
||||
|
||||
(ensureUserRollupsFresh as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
@@ -196,7 +198,7 @@ beforeEach(async () => {
|
||||
(
|
||||
computeCorrelationHypothesesFastPath as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({ hypotheses: [] });
|
||||
(computeSummariesSlice as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
(readSummariesSlice as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
summaries: {},
|
||||
bmi: null,
|
||||
lastSeenByType: {},
|
||||
@@ -206,7 +208,7 @@ beforeEach(async () => {
|
||||
describe("GET /api/analytics — per-type live walk retired (v1.4.49.1)", () => {
|
||||
// v1.4.49.1 — the W-SINCE 425-day `since` cap was a defense-in-depth
|
||||
// bound on the now-deleted 15-way per-type live walk. The default
|
||||
// slice routes through `computeSummariesSlice` exclusively, which
|
||||
// slice routes through `readSummariesSlice` exclusively, which
|
||||
// reads `measurement_rollups` DAY buckets + a 90-day narrow
|
||||
// `$queryRaw` directly — no chunked findMany against `measurements`
|
||||
// at all on the default critical path. The two assertions that pinned
|
||||
@@ -222,7 +224,7 @@ describe("GET /api/analytics — per-type live walk retired (v1.4.49.1)", () =>
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// The slim slice resolves through `computeSummariesSlice` only;
|
||||
// The slim slice resolves through `readSummariesSlice` only;
|
||||
// the per-type findMany loop is skipped entirely so the rollup-
|
||||
// path semantics are unaffected by the `since` cap.
|
||||
const findMany = prisma.measurement.findMany as ReturnType<typeof vi.fn>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* v1.4.40 W-INSIGHTS SB-7 — pin the four discovery branches of
|
||||
* v1.4.40 — pin the four discovery branches of
|
||||
* `GET /api/auth/registration-status`.
|
||||
*
|
||||
* The route is read-only; the iOS sign-in flow + the web onboarding
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* No client component reaches a server-only module.
|
||||
*
|
||||
* A `"use client"` file ships to the browser together with everything it
|
||||
* imports, transitively. Two modules did reach the server side that way: the
|
||||
* side-effect taxonomy imported its enums from the Prisma CLIENT entry, and
|
||||
* the recurrence engine the medication wizard previews schedules with
|
||||
* imported the logging context, which brings `node:async_hooks`, `node:crypto`
|
||||
* and `node:os` with it. The build tolerated both; neither belongs in a
|
||||
* browser bundle, and the next such import could be the database client.
|
||||
*
|
||||
* The project has no `server-only` package to mark modules with, so this
|
||||
* walks the graph instead: from every `"use client"` file, through every
|
||||
* value import (`import type` is erased and not followed), and fails on any
|
||||
* reachable import of the database client, the Prisma client entry (the
|
||||
* `enums` entry is browser-safe and allowed), `next/headers`, the job queue,
|
||||
* or a Node built-in. The failure prints the chain, so the fix is obvious.
|
||||
*
|
||||
* Limits, stated: specifiers are resolved the way the app resolves `@/…` and
|
||||
* relative paths; a re-export through a package outside `src` is not
|
||||
* followed. The floors below fail the guard if the walk finds too little to
|
||||
* mean anything.
|
||||
*/
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
const NODE_BUILTIN =
|
||||
/^(node:|(fs|path|crypto|child_process|net|tls|zlib|stream|os|async_hooks|worker_threads|http|https|dns)(\/|$))/;
|
||||
|
||||
function isServerOnly(spec: string, file: string | null): boolean {
|
||||
if (file) {
|
||||
const rel = file.slice(SRC.length + 1);
|
||||
return rel === "lib/db.ts" || rel.startsWith("generated/prisma/client");
|
||||
}
|
||||
return (
|
||||
NODE_BUILTIN.test(spec) ||
|
||||
spec === "next/headers" ||
|
||||
spec === "pg-boss" ||
|
||||
spec === "pg" ||
|
||||
spec.startsWith("@prisma/")
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSpec(spec: string, from: string): string | null {
|
||||
let base: string;
|
||||
if (spec.startsWith("@/")) base = join(SRC, spec.slice(2));
|
||||
else if (spec.startsWith(".")) base = resolve(dirname(from), spec);
|
||||
else return null;
|
||||
for (const c of [
|
||||
`${base}.ts`,
|
||||
`${base}.tsx`,
|
||||
join(base, "index.ts"),
|
||||
join(base, "index.tsx"),
|
||||
]) {
|
||||
if (existsSync(c) && statSync(c).isFile()) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Value imports only: `import type` and all-`type` named lists are erased. */
|
||||
function valueImports(source: string): string[] {
|
||||
const out: string[] = [];
|
||||
const re =
|
||||
/(?:^|\n)\s*(?:import|export)\s+(type\s+)?([\s\S]{0,600}?)\s*from\s*["']([^"']+)["']/g;
|
||||
for (const m of source.matchAll(re)) {
|
||||
if (m[1]) continue;
|
||||
const clause = m[2].trim();
|
||||
if (/^\{[^}]*\}$/.test(clause)) {
|
||||
const names = clause
|
||||
.replace(/[{}\s]/g, " ")
|
||||
.split(",")
|
||||
.map((n) => n.trim())
|
||||
.filter(Boolean);
|
||||
if (names.length > 0 && names.every((n) => n.startsWith("type ")))
|
||||
continue;
|
||||
}
|
||||
out.push(m[3]);
|
||||
}
|
||||
for (const m of source.matchAll(/(?:^|\n)\s*import\s+["']([^"']+)["']/g))
|
||||
out.push(m[1]);
|
||||
for (const m of source.matchAll(/import\(\s*["']([^"']+)["']\s*\)/g))
|
||||
out.push(m[1]);
|
||||
return out;
|
||||
}
|
||||
|
||||
function walkFromClients() {
|
||||
const files = walkSourceFiles(SRC, { floor: 2000 })
|
||||
.filter((f) => !f.startsWith("generated/") && !f.includes("__tests__/"))
|
||||
.filter((f) => !/\.test\.tsx?$/.test(f))
|
||||
.map((f) => join(SRC, f));
|
||||
const clients = files.filter((f) =>
|
||||
/^\s*["']use client["']/.test(readFileSync(f, "utf8")),
|
||||
);
|
||||
const parent = new Map<string, string | null>(clients.map((c) => [c, null]));
|
||||
const queue = [...clients];
|
||||
const violations: string[] = [];
|
||||
for (let i = 0; i < queue.length; i++) {
|
||||
const file = queue[i];
|
||||
for (const spec of valueImports(readFileSync(file, "utf8"))) {
|
||||
const target = resolveSpec(spec, file);
|
||||
if (isServerOnly(spec, target)) {
|
||||
const chain = [spec];
|
||||
for (let at: string | null = file; at; at = parent.get(at) ?? null) {
|
||||
chain.unshift(at.slice(SRC.length + 1));
|
||||
}
|
||||
violations.push(chain.join(" -> "));
|
||||
continue;
|
||||
}
|
||||
if (target && !parent.has(target)) {
|
||||
parent.set(target, file);
|
||||
queue.push(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { clients: clients.length, reachable: parent.size, violations };
|
||||
}
|
||||
|
||||
describe("client bundle", () => {
|
||||
const result = walkFromClients();
|
||||
|
||||
it("walks enough of the tree to mean something", () => {
|
||||
expect(result.clients).toBeGreaterThan(500);
|
||||
expect(result.reachable).toBeGreaterThan(900);
|
||||
});
|
||||
|
||||
it("reaches no server-only module from a client component", () => {
|
||||
expect(result.violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Structural guard: every client poll is in the inventory, in one of the
|
||||
* three shapes `src/lib/queries/refetch-interval.ts` describes.
|
||||
*
|
||||
* The inventory used to be a comment, and a comment cannot fail: three polls
|
||||
* (the Insights shell's briefing, the medication compliance card and two of
|
||||
* the status hooks' cards) were missing from it. Here it is data, held to
|
||||
* every `refetchInterval:` in the tree, with the number each file carries, so
|
||||
* a new poll in a listed file fails as surely as one in a new file.
|
||||
*
|
||||
* A bounded poll counts its attempts with `pollAttemptCount`, failed fetches
|
||||
* included: on `dataUpdateCount` alone a request that kept failing kept the
|
||||
* last good payload (still `preparing`) and polled for as long as the page
|
||||
* stayed open.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
requireFloor,
|
||||
stripComments,
|
||||
walkSourceFiles,
|
||||
} from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
type Shape = "standing" | "until-done" | "bounded";
|
||||
|
||||
interface Poll {
|
||||
/** How many `refetchInterval:` the file holds. */
|
||||
sites: number;
|
||||
shape: Shape;
|
||||
/** The interval, and why that figure; or what ends the poll. */
|
||||
cadence: string;
|
||||
}
|
||||
|
||||
const INVENTORY: Readonly<Record<string, Poll>> = {
|
||||
// ── Standing: a fixed interval while mounted, paused on a hidden tab ──
|
||||
"lib/queries/use-dashboard-snapshot.ts": {
|
||||
sites: 1,
|
||||
shape: "standing",
|
||||
cadence:
|
||||
"120 s, DASHBOARD_REFETCH_INTERVAL_MS; matches the snapshot cache TTL",
|
||||
},
|
||||
"lib/queries/use-analytics-query.ts": {
|
||||
sites: 1,
|
||||
shape: "standing",
|
||||
cadence: "120 s, DASHBOARD_REFETCH_INTERVAL_MS",
|
||||
},
|
||||
"lib/queries/use-daily-digest.ts": {
|
||||
sites: 1,
|
||||
shape: "standing",
|
||||
cadence: "120 s, DASHBOARD_REFETCH_INTERVAL_MS",
|
||||
},
|
||||
"components/settings/notification-status-card.tsx": {
|
||||
sites: 1,
|
||||
shape: "standing",
|
||||
cadence: "30 s; last-delivery results move while the panel is open",
|
||||
},
|
||||
"components/admin/app-log-preview-section.tsx": {
|
||||
sites: 1,
|
||||
shape: "standing",
|
||||
cadence: "30 s; an operator watching a live log",
|
||||
},
|
||||
"components/gamification/achievement-unlock-notifier.tsx": {
|
||||
sites: 1,
|
||||
shape: "standing",
|
||||
cadence: "5 min; out-of-band unlocks, an app-global poll",
|
||||
},
|
||||
"components/admin/host-metrics-chart.tsx": {
|
||||
sites: 1,
|
||||
shape: "standing",
|
||||
cadence: "5 min; the sampler's own cadence",
|
||||
},
|
||||
// ── Until done: stops on a terminal state the server always reaches ──
|
||||
"components/settings/import-panel/apple-health-import-card.tsx": {
|
||||
sites: 1,
|
||||
shape: "until-done",
|
||||
cadence: "2 s until the job reports a terminal status",
|
||||
},
|
||||
"components/settings/import-panel/health-connect-import-card.tsx": {
|
||||
sites: 1,
|
||||
shape: "until-done",
|
||||
cadence: "2 s until the job reports a terminal status",
|
||||
},
|
||||
"components/admin/backups-section.tsx": {
|
||||
sites: 1,
|
||||
shape: "until-done",
|
||||
cadence: "2 s until no restore is active",
|
||||
},
|
||||
"components/documents/documents-view.tsx": {
|
||||
sites: 1,
|
||||
shape: "until-done",
|
||||
cadence:
|
||||
"4 s until no document is inside its 3-minute upload window without an index",
|
||||
},
|
||||
"components/insights/coach-panel/coach-conversation.tsx": {
|
||||
sites: 1,
|
||||
shape: "until-done",
|
||||
cadence:
|
||||
"2.5 s until the attachment is indexed or its upload window passed",
|
||||
},
|
||||
"components/admin/pulse-day-mean-card.tsx": {
|
||||
sites: 1,
|
||||
shape: "until-done",
|
||||
cadence: "10 s until the recompute reports done",
|
||||
},
|
||||
"components/admin/measurement-maintenance-card.tsx": {
|
||||
sites: 1,
|
||||
shape: "until-done",
|
||||
cadence: "10 s until the run reports done",
|
||||
},
|
||||
"components/insights/period-narrative-card.tsx": {
|
||||
sites: 1,
|
||||
shape: "until-done",
|
||||
cadence:
|
||||
"5 s until the narrative arrives or `revalidating` clears; only mounted while looked at",
|
||||
},
|
||||
// ── Bounded: model text on its way, with an attempt ceiling ──
|
||||
"hooks/use-insight-status.ts": {
|
||||
sites: 3,
|
||||
shape: "bounded",
|
||||
cadence:
|
||||
"4 s, nextStatusPollInterval, 12 attempts (metric, biomarker, general cards)",
|
||||
},
|
||||
"app/insights/medications/page.tsx": {
|
||||
sites: 1,
|
||||
shape: "bounded",
|
||||
cadence: "4 s, nextStatusPollInterval, 12 attempts (compliance card)",
|
||||
},
|
||||
"components/insights/use-insights-advisor.ts": {
|
||||
sites: 1,
|
||||
shape: "bounded",
|
||||
cadence: "25 s, nextAdvisorPollInterval, 10 attempts (advisor)",
|
||||
},
|
||||
"components/insights/insights-layout-shell.tsx": {
|
||||
sites: 1,
|
||||
shape: "bounded",
|
||||
cadence: "25 s, nextAdvisorPollInterval, 10 attempts (briefing body)",
|
||||
},
|
||||
};
|
||||
|
||||
const SITE = /\brefetchInterval\s*:/g;
|
||||
|
||||
const files = walkSourceFiles(SRC, { floor: 3000 }).filter(
|
||||
(f) =>
|
||||
!f.startsWith("generated/") &&
|
||||
!f.includes("__tests__/") &&
|
||||
!/\.test\.tsx?$/.test(f),
|
||||
);
|
||||
const code = new Map(
|
||||
files.map((f) => [f, stripComments(readFileSync(join(SRC, f), "utf8"))]),
|
||||
);
|
||||
const sitesIn = (text: string) => text.match(SITE)?.length ?? 0;
|
||||
|
||||
describe("client polls", () => {
|
||||
it("lists every poll in the tree, with the number each file holds", () => {
|
||||
const found = requireFloor(
|
||||
"refetchInterval files",
|
||||
files.filter((f) => sitesIn(code.get(f)!) > 0),
|
||||
15,
|
||||
);
|
||||
const drift = found
|
||||
.filter((f) => INVENTORY[f]?.sites !== sitesIn(code.get(f)!))
|
||||
.map((f) => `${f}: ${sitesIn(code.get(f)!)}`);
|
||||
expect(drift).toEqual([]);
|
||||
const gone = Object.keys(INVENTORY).filter((f) => !found.includes(f));
|
||||
expect(gone).toEqual([]);
|
||||
});
|
||||
|
||||
it("counts a bounded poll's attempts with the failed fetches", () => {
|
||||
const bounded = Object.entries(INVENTORY).filter(
|
||||
([, p]) => p.shape === "bounded",
|
||||
);
|
||||
const problems = bounded.flatMap(([f, p]) => {
|
||||
const text = code.get(f) ?? "";
|
||||
const counted =
|
||||
text.match(/pollAttemptCount\(query\.state\)/g)?.length ?? 0;
|
||||
return [
|
||||
...(counted < p.sites
|
||||
? [`${f}: ${counted} of ${p.sites} counted`]
|
||||
: []),
|
||||
...(/\.state\.dataUpdateCount\b/.test(text)
|
||||
? [`${f}: reads dataUpdateCount alone`]
|
||||
: []),
|
||||
];
|
||||
});
|
||||
expect(problems).toEqual([]);
|
||||
});
|
||||
|
||||
it("catches an unlisted poll", () => {
|
||||
expect(sitesIn("useQuery({ refetchInterval: 5_000 })")).toBe(1);
|
||||
expect(sitesIn("// refetchInterval: 5_000")).toBe(1);
|
||||
expect(sitesIn(stripComments("// refetchInterval: 5_000"))).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,7 @@ import { gradeVoice } from "@/lib/ai/coach/eval/grade-voice";
|
||||
import { DEFAULT_COACH_PREFS } from "@/lib/validations/coach-prefs";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
const LOCALES: Locale[] = ["de", "en", "fr", "es", "it", "pl", "ko"];
|
||||
const LOCALES: Locale[] = ["de", "en", "fr", "es", "it", "pl", "ko", "nl"];
|
||||
|
||||
/** The rules a voice section must state; `[]` when it states them all. */
|
||||
function missingVoiceRules(prompt: string, lang: "de" | "en"): string[] {
|
||||
|
||||
@@ -42,7 +42,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { consentNeededForProviders } from "@/lib/ai/capabilities/resolve";
|
||||
|
||||
const MESSAGES = join(__dirname, "../../messages");
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko", "nl"] as const;
|
||||
|
||||
function bundle(locale: string): Record<string, unknown> {
|
||||
return JSON.parse(readFileSync(join(MESSAGES, `${locale}.json`), "utf8"));
|
||||
@@ -81,6 +81,7 @@ const FINALITY_PHRASES: Record<string, readonly string[]> = {
|
||||
],
|
||||
pl: ["nie można cofnąć", "nie można tego cofnąć", "trwale", "nieodwracaln"],
|
||||
ko: ["되돌릴 수 없", "복구할 수 없", "영구", "완전히 삭제"],
|
||||
nl: ["niet ongedaan", "definitief", "permanent", "onherroepelijk"],
|
||||
};
|
||||
|
||||
/** Per-locale phrase that promises the short undo window. */
|
||||
@@ -92,6 +93,7 @@ const UNDO_PHRASES: Record<string, string> = {
|
||||
it: "annullare",
|
||||
pl: "cofnąć",
|
||||
ko: "되돌릴 수 있",
|
||||
nl: "ongedaan maken",
|
||||
};
|
||||
|
||||
/** Confirmation bodies whose surface renders an Undo affordance. */
|
||||
@@ -176,6 +178,7 @@ describe("sleep debt is labelled as a balance, not a summed deficit", () => {
|
||||
it: ["cumulato", "cumulata", "accumulato", "deficit"],
|
||||
pl: ["skumulowan", "niedobór", "niedoboru"],
|
||||
ko: ["누적", "부족분", "적자", "쌓인"],
|
||||
nl: ["cumulatief", "opgeteld", "tekort", "opgebouwd"],
|
||||
};
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
@@ -302,6 +305,17 @@ describe("the mood prognosis never states a cause", () => {
|
||||
],
|
||||
pl: ["ponieważ", "powoduje", "z powodu", "wywołuje", "poprawia twój"],
|
||||
ko: ["때문", "탓", "원인", "유발", "초래", "야기", "덕분", "인해"],
|
||||
nl: [
|
||||
"omdat",
|
||||
"veroorzaakt",
|
||||
"leidt tot",
|
||||
"zorgt voor",
|
||||
"vanwege",
|
||||
"verbetert je",
|
||||
"verslechtert je",
|
||||
"verlaagt je",
|
||||
"verhoogt je",
|
||||
],
|
||||
};
|
||||
|
||||
/** Every leaf string under `insights.mood.prognosis`, flattened. */
|
||||
@@ -401,6 +415,11 @@ describe("the MCP token copy never claims the mint is read-only", () => {
|
||||
it: ["token in sola lettura"],
|
||||
pl: ["token tylko do odczytu"],
|
||||
ko: ["읽기 전용 토큰", "읽기전용 토큰"],
|
||||
nl: [
|
||||
"alleen-lezen token",
|
||||
"alleen-lezentoken",
|
||||
"token met alleen leesrechten",
|
||||
],
|
||||
};
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
@@ -450,6 +469,7 @@ describe("AI consent withdrawal copy matches what withdrawing does", () => {
|
||||
it: ["tua chiave", "modello locale"],
|
||||
pl: ["własnym kluczu", "lokalnym modelu"],
|
||||
ko: ["자신의 키", "로컬 모델"],
|
||||
nl: ["eigen sleutel", "lokaal model"],
|
||||
};
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Structural guard: one Pearson correlation, one bar.
|
||||
*
|
||||
* Three implementations used to answer "how correlated are these two
|
||||
* series": the engine's `pearson` (exact p-value, `lib/insights/
|
||||
* correlations.ts`), its gated wrapper `significantPearsonCorrelation`
|
||||
* (n >= 20 and p < 0.05), and an ungated `pearsonCorrelation` that banded a
|
||||
* coefficient from |r| alone above five pairs. The ungated one fed the
|
||||
* /insights envelope and the AI feature payload, so five days of noise
|
||||
* sloping the right way reached a reader labelled "strong" there while every
|
||||
* other surface refused the same five points. It is gone; every surface goes
|
||||
* through the wrapper, and the wrapper through the engine.
|
||||
*
|
||||
* The matcher flags a function whose name says Pearson or correlation
|
||||
* coefficient, declared anywhere but the engine and the wrapper's file, and
|
||||
* any mention of the deleted name. It is a tripwire: a coefficient computed
|
||||
* inline under another name slips it. Comments are stripped first; the
|
||||
* planted cases prove it fires.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { stripComments, walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
const HOMES = new Set([
|
||||
"lib/insights/correlations.ts",
|
||||
"lib/analytics/correlations.ts",
|
||||
]);
|
||||
|
||||
const PEARSON_DEFINITION =
|
||||
/(?:function\s+|const\s+)\w*(?:pearson|correlationCoefficient)\w*\s*(?:=|\()/i;
|
||||
const DELETED = /(?<![A-Za-z])pearsonCorrelation\b/;
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
return walkSourceFiles(SRC, { floor: 3000 }).filter(
|
||||
(p) =>
|
||||
!p.startsWith("generated/") &&
|
||||
!p.includes("__tests__/") &&
|
||||
!/\.test\.tsx?$/.test(p),
|
||||
);
|
||||
}
|
||||
|
||||
describe("one Pearson correlation", () => {
|
||||
it("the matchers fire on a second implementation", () => {
|
||||
expect(PEARSON_DEFINITION.test("export function pearsonR(xs, ys) {")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(PEARSON_DEFINITION.test("const quickPearson = (a, b) =>")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(DELETED.test("pearsonCorrelation(pairs)")).toBe(true);
|
||||
expect(DELETED.test("significantPearsonCorrelation(pairs)")).toBe(false);
|
||||
});
|
||||
|
||||
it("both homes still hold their function", () => {
|
||||
const engine = readFileSync(
|
||||
join(SRC, "lib/insights/correlations.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const wrapper = readFileSync(
|
||||
join(SRC, "lib/analytics/correlations.ts"),
|
||||
"utf8",
|
||||
);
|
||||
expect(engine).toMatch(/export function pearson\s*\(/);
|
||||
expect(wrapper).toMatch(
|
||||
/export function significantPearsonCorrelation\s*\(/,
|
||||
);
|
||||
});
|
||||
|
||||
it("no other file defines a Pearson coefficient or calls the deleted one", () => {
|
||||
// The homes match the definition matcher, so a matcher gone blind fails here.
|
||||
const definers = sourceFiles().filter((file) =>
|
||||
PEARSON_DEFINITION.test(
|
||||
stripComments(readFileSync(join(SRC, file), "utf8")),
|
||||
),
|
||||
);
|
||||
expect(definers.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const offenders = sourceFiles().filter((file) => {
|
||||
const code = stripComments(readFileSync(join(SRC, file), "utf8"));
|
||||
if (DELETED.test(code)) return true;
|
||||
return !HOMES.has(file) && PEARSON_DEFINITION.test(code);
|
||||
});
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -97,10 +97,9 @@ describe("dashboard summary reads — the route keeps using them", () => {
|
||||
expect(route).toMatch(
|
||||
/readSparkBuckets\(\s*prisma,\s*userId,\s*measurementTypes,/,
|
||||
);
|
||||
// No second copy of the rollup read can drift out of the guarded
|
||||
// module. (`FROM measurements` still appears in the route's own
|
||||
// streak-day aggregate, which is a bounded 365-row read and not part
|
||||
// of this finding.)
|
||||
expect(route).toMatch(/readActivityDayKeys\(\s*prisma,\s*userId,/);
|
||||
// No second copy of a read can drift out of the guarded module.
|
||||
expect(route.includes("FROM measurement_rollups")).toBe(false);
|
||||
expect(route.includes("FROM measurements")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -282,7 +282,7 @@ describe("data-wipe completeness", () => {
|
||||
});
|
||||
|
||||
it("freezes the set of user-linked tables that carry no foreign key", () => {
|
||||
// v1.37.20 (A6-19) — NO_FK_BY_DESIGN, as an assertion instead of a pair
|
||||
// v1.37.20 — NO_FK_BY_DESIGN, as an assertion instead of a pair
|
||||
// of FK migrations. The two Telegram plumbing tables link to an account
|
||||
// by a bare `userId` string on purpose: both hold a bounded, short-lived
|
||||
// window of chat state (scheduled message deletions, prompt contexts)
|
||||
|
||||
@@ -133,6 +133,8 @@ const ALLOWED_SQL: Record<string, string> = {
|
||||
"Selects account ids; `measurements` is read inside `EXISTS`, one row per account at most.",
|
||||
"lib/rollups/measurement-coverage.ts::1":
|
||||
"Selects from `unnest` of the caller's type list; `measurements` is read inside `EXISTS`, one row per type at most.",
|
||||
"lib/dashboard/summary-reads.ts::1":
|
||||
"Selects from `generate_series` of the streak window's local days; `measurements` is read inside `EXISTS`, one row per day at most.",
|
||||
};
|
||||
|
||||
interface UntakenRead {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Structural guard: medication compliance is tallied from one ledger.
|
||||
*
|
||||
* The cadence chips carried a private copy of the ledger build that
|
||||
* `lib/analytics/compliance/ledger.ts` owns, minus the pause eras and the
|
||||
* courses, so `/api/medications/[id]/cadence` could report 77 % for a month
|
||||
* the detail page called 100 %. The chips now tally the shared ledger.
|
||||
*
|
||||
* `reconstructDoseHistory` is the step that turns minted bands and intakes
|
||||
* into dose rows. The set of files that call it is frozen: the ledger, the
|
||||
* cadence timeline (the chart and the streaks) and the dose-history view. A
|
||||
* new caller is a new ledger and has to be added here, next to a reason.
|
||||
* Comments are stripped first; the count floor proves the matcher reads.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { stripComments, walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
const CALLERS: Record<string, string> = {
|
||||
"lib/analytics/compliance/ledger.ts":
|
||||
"The compliance ledger every rate and the chips tally.",
|
||||
"lib/medications/scheduling/cadence.ts":
|
||||
"The cadence timeline the chart draws and the streaks walk.",
|
||||
"app/api/medications/[id]/dose-history/route.ts":
|
||||
"The dose-history view, row by row over the same bands.",
|
||||
};
|
||||
|
||||
const CALL = /\breconstructDoseHistory\s*\(/;
|
||||
|
||||
describe("one compliance ledger", () => {
|
||||
it("the dose rows are built only where this file says", () => {
|
||||
const callers = walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter(
|
||||
(file) =>
|
||||
!file.startsWith("generated/") &&
|
||||
!file.includes("__tests__/") &&
|
||||
!/\.test\.tsx?$/.test(file) &&
|
||||
file !== "lib/medications/scheduling/dose-history.ts",
|
||||
)
|
||||
.filter((file) =>
|
||||
CALL.test(stripComments(readFileSync(join(SRC, file), "utf8"))),
|
||||
)
|
||||
.sort();
|
||||
expect(callers.length).toBeGreaterThanOrEqual(3);
|
||||
expect(callers).toEqual(Object.keys(CALLERS).sort());
|
||||
});
|
||||
});
|
||||
@@ -166,7 +166,7 @@ describe("dynamic-key exhaustiveness (enum-derived)", () => {
|
||||
expect(LOCALES.length).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
|
||||
it("discovers all seven shipped locales", () => {
|
||||
it("discovers all eight shipped locales", () => {
|
||||
expect(LOCALES.map((l) => l.locale).sort()).toEqual([
|
||||
"de",
|
||||
"en",
|
||||
@@ -174,6 +174,7 @@ describe("dynamic-key exhaustiveness (enum-derived)", () => {
|
||||
"fr",
|
||||
"it",
|
||||
"ko",
|
||||
"nl",
|
||||
"pl",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Prime client-side message catalogs beyond EN for one test file.
|
||||
*
|
||||
* `vitest.setup.ts` seeds only the EN catalog, because every catalog is
|
||||
* several hundred kilobytes and the setup file runs once per test file. A
|
||||
* test that renders `I18nProvider` in another locale — usually through the
|
||||
* synchronous `renderToStaticMarkup`, which cannot wait for the dynamic
|
||||
* import a locale switch would trigger — calls this at module scope:
|
||||
*
|
||||
* primeLocales("de");
|
||||
* primeLocales(...locales); // every locale the app ships
|
||||
*
|
||||
* It registers a file-level `beforeAll` that loads each catalog through the
|
||||
* production loader, so the cache holds exactly what a locale switch in the
|
||||
* app would put there.
|
||||
*/
|
||||
import { beforeAll } from "vitest";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { loadMessages } from "@/lib/i18n/load-locale";
|
||||
|
||||
export function primeLocales(...wanted: readonly Locale[]): void {
|
||||
beforeAll(async () => {
|
||||
await Promise.all(wanted.map((locale) => loadMessages(locale)));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* The directories under `src/lib` that hold a provider's client and sync,
|
||||
* derived from the connection keys the product knows (`CONNECTION_KEYS`),
|
||||
* so a guard over provider code cannot keep a list of its own that a new
|
||||
* integration slips past. Each key is checked to be a directory: a key that
|
||||
* names none fails here rather than narrowing a guard in silence.
|
||||
*/
|
||||
import { statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { CONNECTION_KEYS } from "@/lib/integrations/connection-health";
|
||||
|
||||
const LIB = join(process.cwd(), "src", "lib");
|
||||
|
||||
export const PROVIDER_DIRS: readonly string[] = CONNECTION_KEYS.map((key) => {
|
||||
if (!statSync(join(LIB, key), { throwIfNoEntry: false })?.isDirectory()) {
|
||||
throw new Error(`provider-dirs: src/lib/${key} is not a directory`);
|
||||
}
|
||||
return key;
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* A session cookie secret of the exact shape `createSession` mints (`hls_`
|
||||
* and 32 random bytes in hex). The session lookup refuses any other shape
|
||||
* before it asks the database, so a fixture that invents a readable secret
|
||||
* tests the refusal, not the lookup.
|
||||
*/
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
export function fixtureSessionSecret(): string {
|
||||
return `hls_${randomBytes(32).toString("hex")}`;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* v1.4.27 B6 / BL-P4-8 — i18n drift-guard.
|
||||
* v1.4.27 — i18n drift-guard.
|
||||
*
|
||||
* Locks down the bundled key sets that ship across all six locales so
|
||||
* a future copy-paste regression can't silently drop a translation
|
||||
@@ -103,7 +103,7 @@ const REQUIRED_KEY_SETS: Array<{
|
||||
"notifications.admin.reminderCheckMissedBody",
|
||||
"notifications.admin.reminderCheckOverdueTitle",
|
||||
"notifications.admin.reminderCheckOverdueBody",
|
||||
// v1.4.27 R5 — offline-geo fallback notifications.
|
||||
// v1.4.27 — offline-geo fallback notifications.
|
||||
"notifications.admin.offlineGeoUnavailableTitle",
|
||||
"notifications.admin.offlineGeoUnavailableBody",
|
||||
],
|
||||
|
||||
@@ -33,7 +33,7 @@ const EN_PATH = join(MESSAGES_DIR, "en.json");
|
||||
// intentionally excluded — it carries accepted anglicisms (e.g. "Health
|
||||
// Score") that would be noise here, and is covered by its own pinned checks in
|
||||
// `i18n-locale-integrity`.
|
||||
const LEAK_LOCALES = ["es", "fr", "it", "pl", "ko"] as const;
|
||||
const LEAK_LOCALES = ["es", "fr", "it", "pl", "ko", "nl"] as const;
|
||||
|
||||
function flattenValues(
|
||||
obj: unknown,
|
||||
@@ -209,6 +209,16 @@ const LEGIT_IDENTICAL = new Set<string>([
|
||||
"settings.nightscoutUrl",
|
||||
"admin.aiServerKey.baseUrlLabel",
|
||||
"admin.webPushVapidSubject",
|
||||
// Dutch spells these exactly as English does: "In bed" and "capsule(s)" are
|
||||
// ordinary Dutch, and the clinical index names and the fatty-acid
|
||||
// abbreviations are written in their international form.
|
||||
"mood.dayContext.linkedInBed",
|
||||
"insights.sleep.stages.inBed",
|
||||
"medications.wizard.steps.step3.unit.capsules",
|
||||
"doctorReport.glucoseClinical.gmi",
|
||||
"insights.bloodGlucose.clinical.advanced.jIndex.label",
|
||||
"nutrients.names.dha",
|
||||
"nutrients.names.epa",
|
||||
]);
|
||||
|
||||
describe("i18n English-leak guard", () => {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* The cached AI insights payload is read and written in one place.
|
||||
*
|
||||
* Since v1.43.7 the payload is sealed in `User.insightsCachedTextEncrypted`
|
||||
* and the readable `insightsCachedText` survives only as a fallback for rows
|
||||
* the backfill has not reached. Two columns that must move together are a
|
||||
* standing invitation to move one: a clear written as
|
||||
* `{ insightsCachedText: null }` — the shape every clear had until this
|
||||
* release — would now leave the whole sealed briefing behind, after a consent
|
||||
* withdrawal of all places. So the names are spelled in
|
||||
* `src/lib/insights/cached-text-cipher.ts`, and every other file goes through
|
||||
* its select, write, clear and open helpers.
|
||||
*
|
||||
* The allowlist below is the set of files with a reason to spell the legacy
|
||||
* name anyway, each stated. A file that joins it has to say why here.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { stripComments, walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
const ALLOWED: Readonly<Record<string, string>> = {
|
||||
"lib/insights/cached-text-cipher.ts": "the one module that owns the columns",
|
||||
"lib/jobs/free-text-encryption-backfill.ts":
|
||||
"the backfill that seals the legacy column",
|
||||
"lib/export/backup-plan.ts":
|
||||
"the backup classification names every User column",
|
||||
"lib/data-wipe/wipe-plan.ts":
|
||||
"the wipe reset names every User column it resets, and resets both",
|
||||
"lib/dashboard/snapshot.ts":
|
||||
"the snapshot builder's input field carries the OPENED text, not the column",
|
||||
"lib/dashboard/snapshot-read.ts":
|
||||
"fills that input field from openInsightsCachedText",
|
||||
};
|
||||
|
||||
/** The legacy name, not its sealed sibling, outside comments. */
|
||||
const LEGACY_NAME = /\binsightsCachedText\b(?!Encrypted)/;
|
||||
|
||||
function filesSpellingTheLegacyName(): string[] {
|
||||
return walkSourceFiles(SRC, { floor: 2000 })
|
||||
.filter((f) => !f.startsWith("generated/"))
|
||||
.filter((f) => !f.includes("__tests__/") && !/\.test\.tsx?$/.test(f))
|
||||
.filter((f) =>
|
||||
LEGACY_NAME.test(stripComments(readFileSync(join(SRC, f), "utf8"))),
|
||||
);
|
||||
}
|
||||
|
||||
describe("insights cache column access", () => {
|
||||
const found = filesSpellingTheLegacyName();
|
||||
|
||||
it("is confined to the cipher module and the stated exceptions", () => {
|
||||
const strays = found.filter((f) => !(f in ALLOWED));
|
||||
expect(strays).toEqual([]);
|
||||
});
|
||||
|
||||
it("still finds every stated exception, so the matcher is live", () => {
|
||||
expect(found.length).toBeGreaterThan(5);
|
||||
expect([...found].sort()).toEqual(Object.keys(ALLOWED).sort());
|
||||
});
|
||||
});
|
||||
Loaded 100 of 1892 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user